Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,18 @@
# StellaOps.Policy.Engine — Agent Charter
## Mission
Stand up the Policy Engine runtime host that evaluates organization policies against SBOM/advisory/VEX inputs with deterministic, replayable results. Deliver the API/worker orchestration, materialization writers, and observability stack described in Epic 2 (Policy Engine v2).
## Scope
- Minimal API host & background workers for policy runs (full, incremental, simulate).
- Mongo persistence for `policies`, `policy_runs`, and `effective_finding_*` collections.
- Change stream listeners and scheduler integration for incremental re-evaluation.
- Authority integration enforcing new `policy:*` and `effective:write` scopes.
- Observability: metrics, traces, structured logs, trace sampling.
## Expectations
- Keep endpoints deterministic, cancellation-aware, and tenant-scoped.
- Only Policy Engine identity performs writes to effective findings.
- Coordinate with Concelier/Excititor/Scheduler guilds for linkset joins and orchestration inputs.
- Update `TASKS.md`, `../../docs/implplan/SPRINTS.md` when status changes.
- Maintain compliance checklists and schema docs alongside code updates.

View File

@@ -0,0 +1,160 @@
using System.Diagnostics.CodeAnalysis;
namespace StellaOps.Policy.Engine.Compilation;
/// <summary>
/// Represents a precise source location within a policy DSL document.
/// </summary>
public readonly struct SourceLocation : IEquatable<SourceLocation>, IComparable<SourceLocation>
{
public SourceLocation(int offset, int line, int column)
{
if (offset < 0)
{
throw new ArgumentOutOfRangeException(nameof(offset));
}
if (line < 1)
{
throw new ArgumentOutOfRangeException(nameof(line));
}
if (column < 1)
{
throw new ArgumentOutOfRangeException(nameof(column));
}
Offset = offset;
Line = line;
Column = column;
}
public int Offset { get; }
public int Line { get; }
public int Column { get; }
public override string ToString() => $"(L{Line}, C{Column})";
public bool Equals(SourceLocation other) =>
Offset == other.Offset && Line == other.Line && Column == other.Column;
public override bool Equals([NotNullWhen(true)] object? obj) =>
obj is SourceLocation other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Offset, Line, Column);
public int CompareTo(SourceLocation other) => Offset.CompareTo(other.Offset);
public static bool operator ==(SourceLocation left, SourceLocation right) => left.Equals(right);
public static bool operator !=(SourceLocation left, SourceLocation right) => !left.Equals(right);
public static bool operator <(SourceLocation left, SourceLocation right) => left.CompareTo(right) < 0;
public static bool operator <=(SourceLocation left, SourceLocation right) => left.CompareTo(right) <= 0;
public static bool operator >(SourceLocation left, SourceLocation right) => left.CompareTo(right) > 0;
public static bool operator >=(SourceLocation left, SourceLocation right) => left.CompareTo(right) >= 0;
}
/// <summary>
/// Represents a start/end location pair within a policy DSL source document.
/// </summary>
public readonly struct SourceSpan : IEquatable<SourceSpan>
{
public SourceSpan(SourceLocation start, SourceLocation end)
{
if (start.Offset > end.Offset)
{
throw new ArgumentException("Start must not be after end.", nameof(start));
}
Start = start;
End = end;
}
public SourceLocation Start { get; }
public SourceLocation End { get; }
public override string ToString() => $"{Start}->{End}";
public bool Equals(SourceSpan other) => Start.Equals(other.Start) && End.Equals(other.End);
public override bool Equals([NotNullWhen(true)] object? obj) => obj is SourceSpan other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Start, End);
public static SourceSpan Combine(SourceSpan first, SourceSpan second)
{
var start = first.Start <= second.Start ? first.Start : second.Start;
var end = first.End >= second.End ? first.End : second.End;
return new SourceSpan(start, end);
}
}
internal enum TokenKind
{
EndOfFile = 0,
Identifier,
StringLiteral,
NumberLiteral,
BooleanLiteral,
LeftBrace,
RightBrace,
LeftParen,
RightParen,
LeftBracket,
RightBracket,
Comma,
Semicolon,
Colon,
Arrow, // =>
Assign, // =
Define, // :=
Dot,
KeywordPolicy,
KeywordSyntax,
KeywordMetadata,
KeywordProfile,
KeywordRule,
KeywordMap,
KeywordSource,
KeywordEnv,
KeywordIf,
KeywordThen,
KeywordWhen,
KeywordAnd,
KeywordOr,
KeywordNot,
KeywordPriority,
KeywordElse,
KeywordBecause,
KeywordSettings,
KeywordIgnore,
KeywordUntil,
KeywordEscalate,
KeywordTo,
KeywordRequireVex,
KeywordWarn,
KeywordMessage,
KeywordDefer,
KeywordAnnotate,
KeywordIn,
EqualEqual,
NotEqual,
LessThan,
LessThanOrEqual,
GreaterThan,
GreaterThanOrEqual,
Unknown,
}
internal readonly record struct DslToken(
TokenKind Kind,
string Text,
SourceSpan Span,
object? Value = null);

View File

@@ -0,0 +1,576 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Text;
using StellaOps.Policy;
namespace StellaOps.Policy.Engine.Compilation;
internal static class DslTokenizer
{
public static TokenizerResult Tokenize(string source)
{
if (source is null)
{
throw new ArgumentNullException(nameof(source));
}
var tokens = ImmutableArray.CreateBuilder<DslToken>();
var diagnostics = ImmutableArray.CreateBuilder<PolicyIssue>();
var index = 0;
var line = 1;
var column = 1;
while (index < source.Length)
{
var current = source[index];
if (char.IsWhiteSpace(current))
{
(index, line, column) = AdvanceWhitespace(source, index, line, column);
continue;
}
if (current == '/' && index + 1 < source.Length)
{
if (source[index + 1] == '/')
{
(index, line, column) = SkipSingleLineComment(source, index + 2, line, column + 2);
continue;
}
if (source[index + 1] == '*')
{
(index, line, column) = SkipMultiLineComment(source, index + 2, line, column + 2, diagnostics);
continue;
}
}
var startLocation = new SourceLocation(index, line, column);
switch (current)
{
case '{':
tokens.Add(CreateToken(TokenKind.LeftBrace, "{", startLocation, ref index, ref column));
break;
case '}':
tokens.Add(CreateToken(TokenKind.RightBrace, "}", startLocation, ref index, ref column));
break;
case '(':
tokens.Add(CreateToken(TokenKind.LeftParen, "(", startLocation, ref index, ref column));
break;
case ')':
tokens.Add(CreateToken(TokenKind.RightParen, ")", startLocation, ref index, ref column));
break;
case '[':
tokens.Add(CreateToken(TokenKind.LeftBracket, "[", startLocation, ref index, ref column));
break;
case ']':
tokens.Add(CreateToken(TokenKind.RightBracket, "]", startLocation, ref index, ref column));
break;
case ',':
tokens.Add(CreateToken(TokenKind.Comma, ",", startLocation, ref index, ref column));
break;
case ';':
tokens.Add(CreateToken(TokenKind.Semicolon, ";", startLocation, ref index, ref column));
break;
case ':':
{
if (Match(source, index + 1, '='))
{
tokens.Add(CreateToken(TokenKind.Define, ":=", startLocation, ref index, ref column, advance: 2));
}
else
{
tokens.Add(CreateToken(TokenKind.Colon, ":", startLocation, ref index, ref column));
}
break;
}
case '=':
{
if (Match(source, index + 1, '>'))
{
tokens.Add(CreateToken(TokenKind.Arrow, "=>", startLocation, ref index, ref column, advance: 2));
}
else if (Match(source, index + 1, '='))
{
tokens.Add(CreateToken(TokenKind.EqualEqual, "==", startLocation, ref index, ref column, advance: 2));
}
else
{
tokens.Add(CreateToken(TokenKind.Assign, "=", startLocation, ref index, ref column));
}
break;
}
case '!':
{
if (Match(source, index + 1, '='))
{
tokens.Add(CreateToken(TokenKind.NotEqual, "!=", startLocation, ref index, ref column, advance: 2));
}
else
{
ReportUnexpectedCharacter(diagnostics, current, startLocation);
index++;
column++;
}
break;
}
case '<':
{
if (Match(source, index + 1, '='))
{
tokens.Add(CreateToken(TokenKind.LessThanOrEqual, "<=", startLocation, ref index, ref column, advance: 2));
}
else
{
tokens.Add(CreateToken(TokenKind.LessThan, "<", startLocation, ref index, ref column));
}
break;
}
case '>':
{
if (Match(source, index + 1, '='))
{
tokens.Add(CreateToken(TokenKind.GreaterThanOrEqual, ">=", startLocation, ref index, ref column, advance: 2));
}
else
{
tokens.Add(CreateToken(TokenKind.GreaterThan, ">", startLocation, ref index, ref column));
}
break;
}
case '.':
tokens.Add(CreateToken(TokenKind.Dot, ".", startLocation, ref index, ref column));
break;
case '"':
TokenizeString(source, ref index, ref line, ref column, startLocation, tokens, diagnostics);
break;
case '+':
case '-':
{
if (index + 1 < source.Length && char.IsDigit(source[index + 1]))
{
TokenizeNumber(source, ref index, ref line, ref column, startLocation, tokens, diagnostics);
}
else
{
ReportUnexpectedCharacter(diagnostics, current, startLocation);
index++;
column++;
}
break;
}
default:
{
if (char.IsDigit(current))
{
TokenizeNumber(source, ref index, ref line, ref column, startLocation, tokens, diagnostics);
}
else if (IsIdentifierStart(current))
{
TokenizeIdentifierOrKeyword(source, ref index, ref line, ref column, startLocation, tokens);
}
else
{
ReportUnexpectedCharacter(diagnostics, current, startLocation);
index++;
column++;
}
break;
}
}
}
var eofLocation = new SourceLocation(index, line, column);
tokens.Add(new DslToken(TokenKind.EndOfFile, string.Empty, new SourceSpan(eofLocation, eofLocation)));
return new TokenizerResult(tokens.ToImmutable(), diagnostics.ToImmutable());
}
private static void TokenizeString(
string source,
ref int index,
ref int line,
ref int column,
SourceLocation start,
ImmutableArray<DslToken>.Builder tokens,
ImmutableArray<PolicyIssue>.Builder diagnostics)
{
var builder = new StringBuilder();
var i = index + 1;
var currentLine = line;
var currentColumn = column + 1;
while (i < source.Length)
{
var ch = source[i];
if (ch == '"')
{
var end = new SourceLocation(i + 1, currentLine, currentColumn + 1);
index = i + 1;
column = currentColumn + 1;
tokens.Add(new DslToken(TokenKind.StringLiteral, builder.ToString(), new SourceSpan(start, end), builder.ToString()));
return;
}
if (ch == '\\')
{
if (i + 1 >= source.Length)
{
diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.UnterminatedString, "Unterminated string literal.", $"@{start.Line}:{start.Column}"));
index = source.Length;
line = currentLine;
column = currentColumn;
return;
}
var escape = source[i + 1];
switch (escape)
{
case '\\':
builder.Append('\\');
break;
case '"':
builder.Append('"');
break;
case 'n':
builder.Append('\n');
break;
case 'r':
builder.Append('\r');
break;
case 't':
builder.Append('\t');
break;
default:
diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.InvalidEscapeSequence, $"Invalid escape sequence '\\{escape}'.", $"@{currentLine}:{currentColumn}"));
builder.Append(escape);
break;
}
i += 2;
currentColumn += 2;
continue;
}
if (ch == '\r' || ch == '\n')
{
diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.UnterminatedString, "Unterminated string literal.", $"@{start.Line}:{start.Column}"));
(index, line, column) = AdvanceWhitespace(source, i, currentLine, currentColumn);
return;
}
builder.Append(ch);
i++;
currentColumn++;
}
diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.UnterminatedString, "Unterminated string literal.", $"@{start.Line}:{start.Column}"));
index = source.Length;
line = currentLine;
column = currentColumn;
}
private static void TokenizeNumber(
string source,
ref int index,
ref int line,
ref int column,
SourceLocation start,
ImmutableArray<DslToken>.Builder tokens,
ImmutableArray<PolicyIssue>.Builder diagnostics)
{
var i = index;
var hasDecimal = false;
if (source[i] == '+' || source[i] == '-')
{
i++;
}
while (i < source.Length)
{
var ch = source[i];
if (char.IsDigit(ch))
{
i++;
continue;
}
if (ch == '.')
{
if (hasDecimal)
{
break;
}
hasDecimal = true;
i++;
continue;
}
break;
}
var percent = false;
if (i < source.Length && source[i] == '%')
{
percent = true;
i++;
}
var text = source.Substring(index, i - index);
if (!decimal.TryParse(text, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var value))
{
diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.InvalidNumber, $"Invalid numeric literal '{text}'.", $"@{start.Line}:{start.Column}"));
index = i;
column += i - index;
return;
}
if (percent)
{
value /= 100m;
}
var end = new SourceLocation(i, line, column + (i - index));
tokens.Add(new DslToken(TokenKind.NumberLiteral, text, new SourceSpan(start, end), value));
column += i - index;
index = i;
}
private static void TokenizeIdentifierOrKeyword(
string source,
ref int index,
ref int line,
ref int column,
SourceLocation start,
ImmutableArray<DslToken>.Builder tokens)
{
var i = index + 1;
while (i < source.Length && IsIdentifierPart(source[i]))
{
i++;
}
var text = source.Substring(index, i - index);
var kind = GetKeywordKind(text);
if (kind == TokenKind.BooleanLiteral)
{
var value = string.Equals(text, "true", StringComparison.Ordinal);
var end = new SourceLocation(i, line, column + (i - index));
tokens.Add(new DslToken(TokenKind.BooleanLiteral, text, new SourceSpan(start, end), value));
}
else if (kind == TokenKind.Identifier)
{
var end = new SourceLocation(i, line, column + (i - index));
tokens.Add(new DslToken(TokenKind.Identifier, text, new SourceSpan(start, end)));
}
else
{
var end = new SourceLocation(i, line, column + (i - index));
tokens.Add(new DslToken(kind, text, new SourceSpan(start, end)));
}
column += i - index;
index = i;
}
private static TokenKind GetKeywordKind(string text)
{
return text switch
{
"policy" => TokenKind.KeywordPolicy,
"syntax" => TokenKind.KeywordSyntax,
"metadata" => TokenKind.KeywordMetadata,
"profile" => TokenKind.KeywordProfile,
"rule" => TokenKind.KeywordRule,
"map" => TokenKind.KeywordMap,
"source" => TokenKind.KeywordSource,
"env" => TokenKind.Identifier,
"if" => TokenKind.KeywordIf,
"then" => TokenKind.KeywordThen,
"when" => TokenKind.KeywordWhen,
"and" => TokenKind.KeywordAnd,
"or" => TokenKind.KeywordOr,
"not" => TokenKind.KeywordNot,
"priority" => TokenKind.KeywordPriority,
"else" => TokenKind.KeywordElse,
"because" => TokenKind.KeywordBecause,
"settings" => TokenKind.KeywordSettings,
"ignore" => TokenKind.KeywordIgnore,
"until" => TokenKind.KeywordUntil,
"escalate" => TokenKind.KeywordEscalate,
"to" => TokenKind.KeywordTo,
"requireVex" => TokenKind.KeywordRequireVex,
"warn" => TokenKind.KeywordWarn,
"message" => TokenKind.KeywordMessage,
"defer" => TokenKind.KeywordDefer,
"annotate" => TokenKind.KeywordAnnotate,
"in" => TokenKind.KeywordIn,
"true" => TokenKind.BooleanLiteral,
"false" => TokenKind.BooleanLiteral,
_ => TokenKind.Identifier,
};
}
private static bool IsIdentifierStart(char ch) => char.IsLetter(ch) || ch == '_';
private static bool IsIdentifierPart(char ch) => char.IsLetterOrDigit(ch) || ch == '_' || ch == '-';
private static (int Index, int Line, int Column) AdvanceWhitespace(string source, int index, int line, int column)
{
var i = index;
var currentLine = line;
var currentColumn = column;
while (i < source.Length)
{
var ch = source[i];
if (ch == '\r')
{
if (i + 1 < source.Length && source[i + 1] == '\n')
{
i += 2;
}
else
{
i++;
}
currentLine++;
currentColumn = 1;
continue;
}
if (ch == '\n')
{
i++;
currentLine++;
currentColumn = 1;
continue;
}
if (!char.IsWhiteSpace(ch))
{
break;
}
i++;
currentColumn++;
}
return (i, currentLine, currentColumn);
}
private static (int Index, int Line, int Column) SkipSingleLineComment(string source, int index, int line, int column)
{
var i = index;
var currentLine = line;
var currentColumn = column;
while (i < source.Length)
{
var ch = source[i];
if (ch == '\r' || ch == '\n')
{
return AdvanceWhitespace(source, i, currentLine, currentColumn);
}
i++;
currentColumn++;
}
return (i, currentLine, currentColumn);
}
private static (int Index, int Line, int Column) SkipMultiLineComment(
string source,
int index,
int line,
int column,
ImmutableArray<PolicyIssue>.Builder diagnostics)
{
var i = index;
var currentLine = line;
var currentColumn = column;
while (i < source.Length)
{
var ch = source[i];
if (ch == '*' && i + 1 < source.Length && source[i + 1] == '/')
{
return (i + 2, currentLine, currentColumn + 2);
}
if (ch == '\r')
{
if (i + 1 < source.Length && source[i + 1] == '\n')
{
i += 2;
}
else
{
i++;
}
currentLine++;
currentColumn = 1;
continue;
}
if (ch == '\n')
{
i++;
currentLine++;
currentColumn = 1;
continue;
}
i++;
currentColumn++;
}
diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.UnexpectedCharacter, "Unterminated comment block.", $"@{line}:{column}"));
return (source.Length, currentLine, currentColumn);
}
private static DslToken CreateToken(
TokenKind kind,
string text,
SourceLocation start,
ref int index,
ref int column,
int advance = 1)
{
var end = new SourceLocation(index + advance, start.Line, start.Column + advance);
index += advance;
column += advance;
return new DslToken(kind, text, new SourceSpan(start, end));
}
private static void ReportUnexpectedCharacter(
ImmutableArray<PolicyIssue>.Builder diagnostics,
char ch,
SourceLocation location)
{
diagnostics.Add(PolicyIssue.Error(
PolicyDslDiagnosticCodes.UnexpectedCharacter,
$"Unexpected character '{ch}'.",
$"@{location.Line}:{location.Column}"));
}
private static bool Match(string source, int index, char expected) =>
index < source.Length && source[index] == expected;
}
internal readonly record struct TokenizerResult(
ImmutableArray<DslToken> Tokens,
ImmutableArray<PolicyIssue> Diagnostics);

View File

@@ -0,0 +1,169 @@
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using StellaOps.Policy;
namespace StellaOps.Policy.Engine.Compilation;
public sealed class PolicyCompiler
{
public PolicyCompilationResult Compile(string source)
{
if (source is null)
{
throw new ArgumentNullException(nameof(source));
}
var parseResult = PolicyParser.Parse(source);
if (parseResult.Document is null)
{
return new PolicyCompilationResult(
Success: false,
Document: null,
Checksum: null,
CanonicalRepresentation: ImmutableArray<byte>.Empty,
Diagnostics: parseResult.Diagnostics);
}
if (parseResult.Diagnostics.Any(static issue => issue.Severity == PolicyIssueSeverity.Error))
{
return new PolicyCompilationResult(
Success: false,
Document: null,
Checksum: null,
CanonicalRepresentation: ImmutableArray<byte>.Empty,
Diagnostics: parseResult.Diagnostics);
}
var irDocument = BuildIntermediateRepresentation(parseResult.Document);
var canonical = PolicyIrSerializer.Serialize(irDocument);
var checksum = Convert.ToHexString(SHA256.HashData(canonical.AsSpan())).ToLowerInvariant();
return new PolicyCompilationResult(
Success: true,
Document: irDocument,
Checksum: checksum,
CanonicalRepresentation: canonical,
Diagnostics: parseResult.Diagnostics);
}
private static PolicyIrDocument BuildIntermediateRepresentation(PolicyDocumentNode node)
{
var metadata = node.Metadata
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static kvp => kvp.Key, kvp => ToIrLiteral(kvp.Value), StringComparer.Ordinal);
var settings = node.Settings
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static kvp => kvp.Key, kvp => ToIrLiteral(kvp.Value), StringComparer.Ordinal);
var profiles = ImmutableArray.CreateBuilder<PolicyIrProfile>(node.Profiles.Length);
foreach (var profile in node.Profiles)
{
var maps = ImmutableArray.CreateBuilder<PolicyIrProfileMap>();
var envs = ImmutableArray.CreateBuilder<PolicyIrProfileEnv>();
var scalars = ImmutableArray.CreateBuilder<PolicyIrProfileScalar>();
foreach (var item in profile.Items)
{
switch (item)
{
case PolicyProfileMapNode map:
maps.Add(new PolicyIrProfileMap(
map.Name,
map.Entries
.Select(entry => new PolicyIrProfileMapEntry(entry.Source, entry.Weight))
.ToImmutableArray()));
break;
case PolicyProfileEnvNode env:
envs.Add(new PolicyIrProfileEnv(
env.Name,
env.Entries
.Select(entry => new PolicyIrProfileEnvEntry(entry.Condition, entry.Weight))
.ToImmutableArray()));
break;
case PolicyProfileScalarNode scalar:
scalars.Add(new PolicyIrProfileScalar(scalar.Name, ToIrLiteral(scalar.Value)));
break;
}
}
profiles.Add(new PolicyIrProfile(
profile.Name,
maps.ToImmutable(),
envs.ToImmutable(),
scalars.ToImmutable()));
}
var rules = ImmutableArray.CreateBuilder<PolicyIrRule>(node.Rules.Length);
foreach (var rule in node.Rules)
{
var thenActions = ImmutableArray.CreateBuilder<PolicyIrAction>(rule.ThenActions.Length);
foreach (var action in rule.ThenActions)
{
var converted = ToIrAction(action);
if (converted is not null)
{
thenActions.Add(converted);
}
}
var elseActions = ImmutableArray.CreateBuilder<PolicyIrAction>(rule.ElseActions.Length);
foreach (var action in rule.ElseActions)
{
var converted = ToIrAction(action);
if (converted is not null)
{
elseActions.Add(converted);
}
}
rules.Add(new PolicyIrRule(
rule.Name,
rule.Priority,
rule.When,
thenActions.ToImmutable(),
elseActions.ToImmutable(),
rule.Because ?? string.Empty));
}
return new PolicyIrDocument(
node.Name,
node.Syntax,
metadata,
profiles.ToImmutable(),
settings,
rules.ToImmutable());
}
private static PolicyIrLiteral ToIrLiteral(PolicyLiteralValue value) => value switch
{
PolicyStringLiteral s => new PolicyIrStringLiteral(s.Value),
PolicyNumberLiteral n => new PolicyIrNumberLiteral(n.Value),
PolicyBooleanLiteral b => new PolicyIrBooleanLiteral(b.Value),
PolicyListLiteral list => new PolicyIrListLiteral(list.Items.Select(ToIrLiteral).ToImmutableArray()),
_ => new PolicyIrStringLiteral(string.Empty),
};
private static PolicyIrAction? ToIrAction(PolicyActionNode action) => action switch
{
PolicyAssignmentActionNode assign => new PolicyIrAssignmentAction(assign.Target.Segments, assign.Value),
PolicyAnnotateActionNode annotate => new PolicyIrAnnotateAction(annotate.Target.Segments, annotate.Value),
PolicyIgnoreActionNode ignore => new PolicyIrIgnoreAction(ignore.Until, ignore.Because),
PolicyEscalateActionNode escalate => new PolicyIrEscalateAction(escalate.To, escalate.When),
PolicyRequireVexActionNode require => new PolicyIrRequireVexAction(
require.Conditions
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal)),
PolicyWarnActionNode warn => new PolicyIrWarnAction(warn.Message),
PolicyDeferActionNode defer => new PolicyIrDeferAction(defer.Until),
_ => null,
};
}
public sealed record PolicyCompilationResult(
bool Success,
PolicyIrDocument? Document,
string? Checksum,
ImmutableArray<byte> CanonicalRepresentation,
ImmutableArray<PolicyIssue> Diagnostics);

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Policy.Engine.Compilation;
internal static class PolicyDslDiagnosticCodes
{
public const string UnexpectedCharacter = "POLICY-DSL-LEX-001";
public const string UnterminatedString = "POLICY-DSL-LEX-002";
public const string InvalidEscapeSequence = "POLICY-DSL-LEX-003";
public const string InvalidNumber = "POLICY-DSL-LEX-004";
public const string UnexpectedToken = "POLICY-DSL-PARSE-001";
public const string DuplicateSection = "POLICY-DSL-PARSE-002";
public const string MissingPolicyHeader = "POLICY-DSL-PARSE-003";
public const string UnsupportedSyntaxVersion = "POLICY-DSL-PARSE-004";
public const string DuplicateRuleName = "POLICY-DSL-PARSE-005";
public const string MissingBecauseClause = "POLICY-DSL-PARSE-006";
public const string MissingTerminator = "POLICY-DSL-PARSE-007";
public const string InvalidAction = "POLICY-DSL-PARSE-008";
public const string InvalidLiteral = "POLICY-DSL-PARSE-009";
public const string UnexpectedSection = "POLICY-DSL-PARSE-010";
}

View File

@@ -0,0 +1,61 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.Engine.Compilation;
public sealed record PolicyIrDocument(
string Name,
string Syntax,
ImmutableSortedDictionary<string, PolicyIrLiteral> Metadata,
ImmutableArray<PolicyIrProfile> Profiles,
ImmutableSortedDictionary<string, PolicyIrLiteral> Settings,
ImmutableArray<PolicyIrRule> Rules);
public abstract record PolicyIrLiteral;
public sealed record PolicyIrStringLiteral(string Value) : PolicyIrLiteral;
public sealed record PolicyIrNumberLiteral(decimal Value) : PolicyIrLiteral;
public sealed record PolicyIrBooleanLiteral(bool Value) : PolicyIrLiteral;
public sealed record PolicyIrListLiteral(ImmutableArray<PolicyIrLiteral> Items) : PolicyIrLiteral;
public sealed record PolicyIrProfile(
string Name,
ImmutableArray<PolicyIrProfileMap> Maps,
ImmutableArray<PolicyIrProfileEnv> Environments,
ImmutableArray<PolicyIrProfileScalar> Scalars);
public sealed record PolicyIrProfileMap(string Name, ImmutableArray<PolicyIrProfileMapEntry> Entries);
public sealed record PolicyIrProfileMapEntry(string Source, decimal Weight);
public sealed record PolicyIrProfileEnv(string Name, ImmutableArray<PolicyIrProfileEnvEntry> Entries);
public sealed record PolicyIrProfileEnvEntry(PolicyExpression Condition, decimal Weight);
public sealed record PolicyIrProfileScalar(string Name, PolicyIrLiteral Value);
public sealed record PolicyIrRule(
string Name,
int Priority,
PolicyExpression When,
ImmutableArray<PolicyIrAction> ThenActions,
ImmutableArray<PolicyIrAction> ElseActions,
string Because);
public abstract record PolicyIrAction;
public sealed record PolicyIrAssignmentAction(ImmutableArray<string> Target, PolicyExpression Value) : PolicyIrAction;
public sealed record PolicyIrAnnotateAction(ImmutableArray<string> Target, PolicyExpression Value) : PolicyIrAction;
public sealed record PolicyIrIgnoreAction(PolicyExpression? Until, string? Because) : PolicyIrAction;
public sealed record PolicyIrEscalateAction(PolicyExpression? To, PolicyExpression? When) : PolicyIrAction;
public sealed record PolicyIrRequireVexAction(ImmutableSortedDictionary<string, PolicyExpression> Conditions) : PolicyIrAction;
public sealed record PolicyIrWarnAction(PolicyExpression? Message) : PolicyIrAction;
public sealed record PolicyIrDeferAction(PolicyExpression? Until) : PolicyIrAction;

View File

@@ -0,0 +1,415 @@
using System.Buffers;
using System.Collections.Immutable;
using System.Text.Json;
namespace StellaOps.Policy.Engine.Compilation;
internal static class PolicyIrSerializer
{
public static ImmutableArray<byte> Serialize(PolicyIrDocument document)
{
var buffer = new ArrayBufferWriter<byte>();
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
{
Indented = false,
SkipValidation = false
});
WriteDocument(writer, document);
writer.Flush();
return buffer.WrittenSpan.ToArray().ToImmutableArray();
}
private static void WriteDocument(Utf8JsonWriter writer, PolicyIrDocument document)
{
writer.WriteStartObject();
writer.WriteString("name", document.Name);
writer.WriteString("syntax", document.Syntax);
writer.WritePropertyName("metadata");
WriteLiteralDictionary(writer, document.Metadata);
writer.WritePropertyName("profiles");
writer.WriteStartArray();
foreach (var profile in document.Profiles)
{
WriteProfile(writer, profile);
}
writer.WriteEndArray();
writer.WritePropertyName("settings");
WriteLiteralDictionary(writer, document.Settings);
writer.WritePropertyName("rules");
writer.WriteStartArray();
foreach (var rule in document.Rules)
{
WriteRule(writer, rule);
}
writer.WriteEndArray();
writer.WriteEndObject();
}
private static void WriteProfile(Utf8JsonWriter writer, PolicyIrProfile profile)
{
writer.WriteStartObject();
writer.WriteString("name", profile.Name);
writer.WritePropertyName("maps");
writer.WriteStartArray();
foreach (var map in profile.Maps)
{
writer.WriteStartObject();
writer.WriteString("name", map.Name);
writer.WritePropertyName("entries");
writer.WriteStartArray();
foreach (var entry in map.Entries)
{
writer.WriteStartObject();
writer.WriteString("source", entry.Source);
writer.WriteNumber("weight", entry.Weight);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WritePropertyName("env");
writer.WriteStartArray();
foreach (var env in profile.Environments)
{
writer.WriteStartObject();
writer.WriteString("name", env.Name);
writer.WritePropertyName("entries");
writer.WriteStartArray();
foreach (var entry in env.Entries)
{
writer.WriteStartObject();
writer.WritePropertyName("condition");
WriteExpression(writer, entry.Condition);
writer.WriteNumber("weight", entry.Weight);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WritePropertyName("scalars");
writer.WriteStartArray();
foreach (var scalar in profile.Scalars)
{
writer.WriteStartObject();
writer.WriteString("name", scalar.Name);
writer.WritePropertyName("value");
WriteLiteral(writer, scalar.Value);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
private static void WriteRule(Utf8JsonWriter writer, PolicyIrRule rule)
{
writer.WriteStartObject();
writer.WriteString("name", rule.Name);
writer.WriteNumber("priority", rule.Priority);
writer.WritePropertyName("when");
WriteExpression(writer, rule.When);
writer.WritePropertyName("then");
WriteActions(writer, rule.ThenActions);
writer.WritePropertyName("else");
WriteActions(writer, rule.ElseActions);
writer.WriteString("because", rule.Because);
writer.WriteEndObject();
}
private static void WriteActions(Utf8JsonWriter writer, ImmutableArray<PolicyIrAction> actions)
{
writer.WriteStartArray();
foreach (var action in actions)
{
WriteAction(writer, action);
}
writer.WriteEndArray();
}
private static void WriteAction(Utf8JsonWriter writer, PolicyIrAction action)
{
switch (action)
{
case PolicyIrAssignmentAction assign:
writer.WriteStartObject();
writer.WriteString("type", "assign");
WriteReference(writer, assign.Target);
writer.WritePropertyName("value");
WriteExpression(writer, assign.Value);
writer.WriteEndObject();
break;
case PolicyIrAnnotateAction annotate:
writer.WriteStartObject();
writer.WriteString("type", "annotate");
WriteReference(writer, annotate.Target);
writer.WritePropertyName("value");
WriteExpression(writer, annotate.Value);
writer.WriteEndObject();
break;
case PolicyIrIgnoreAction ignore:
writer.WriteStartObject();
writer.WriteString("type", "ignore");
writer.WritePropertyName("until");
WriteOptionalExpression(writer, ignore.Until);
writer.WriteString("because", ignore.Because ?? string.Empty);
writer.WriteEndObject();
break;
case PolicyIrEscalateAction escalate:
writer.WriteStartObject();
writer.WriteString("type", "escalate");
writer.WritePropertyName("to");
WriteOptionalExpression(writer, escalate.To);
writer.WritePropertyName("when");
WriteOptionalExpression(writer, escalate.When);
writer.WriteEndObject();
break;
case PolicyIrRequireVexAction require:
writer.WriteStartObject();
writer.WriteString("type", "requireVex");
writer.WritePropertyName("conditions");
writer.WriteStartObject();
foreach (var kvp in require.Conditions)
{
writer.WritePropertyName(kvp.Key);
WriteExpression(writer, kvp.Value);
}
writer.WriteEndObject();
writer.WriteEndObject();
break;
case PolicyIrWarnAction warn:
writer.WriteStartObject();
writer.WriteString("type", "warn");
writer.WritePropertyName("message");
WriteOptionalExpression(writer, warn.Message);
writer.WriteEndObject();
break;
case PolicyIrDeferAction defer:
writer.WriteStartObject();
writer.WriteString("type", "defer");
writer.WritePropertyName("until");
WriteOptionalExpression(writer, defer.Until);
writer.WriteEndObject();
break;
}
}
private static void WriteReference(Utf8JsonWriter writer, ImmutableArray<string> segments)
{
writer.WritePropertyName("target");
writer.WriteStartArray();
foreach (var segment in segments)
{
writer.WriteStringValue(segment);
}
writer.WriteEndArray();
}
private static void WriteOptionalExpression(Utf8JsonWriter writer, PolicyExpression? expression)
{
if (expression is null)
{
writer.WriteNullValue();
return;
}
WriteExpression(writer, expression);
}
private static void WriteExpression(Utf8JsonWriter writer, PolicyExpression expression)
{
switch (expression)
{
case PolicyLiteralExpression literal:
writer.WriteStartObject();
writer.WriteString("type", "literal");
writer.WritePropertyName("value");
WriteLiteralValue(writer, literal.Value);
writer.WriteEndObject();
break;
case PolicyListExpression list:
writer.WriteStartObject();
writer.WriteString("type", "list");
writer.WritePropertyName("items");
writer.WriteStartArray();
foreach (var item in list.Items)
{
WriteExpression(writer, item);
}
writer.WriteEndArray();
writer.WriteEndObject();
break;
case PolicyIdentifierExpression identifier:
writer.WriteStartObject();
writer.WriteString("type", "identifier");
writer.WriteString("name", identifier.Name);
writer.WriteEndObject();
break;
case PolicyMemberAccessExpression member:
writer.WriteStartObject();
writer.WriteString("type", "member");
writer.WritePropertyName("target");
WriteExpression(writer, member.Target);
writer.WriteString("member", member.Member);
writer.WriteEndObject();
break;
case PolicyInvocationExpression invocation:
writer.WriteStartObject();
writer.WriteString("type", "call");
writer.WritePropertyName("target");
WriteExpression(writer, invocation.Target);
writer.WritePropertyName("args");
writer.WriteStartArray();
foreach (var arg in invocation.Arguments)
{
WriteExpression(writer, arg);
}
writer.WriteEndArray();
writer.WriteEndObject();
break;
case PolicyIndexerExpression indexer:
writer.WriteStartObject();
writer.WriteString("type", "indexer");
writer.WritePropertyName("target");
WriteExpression(writer, indexer.Target);
writer.WritePropertyName("index");
WriteExpression(writer, indexer.Index);
writer.WriteEndObject();
break;
case PolicyUnaryExpression unary:
writer.WriteStartObject();
writer.WriteString("type", "unary");
writer.WriteString("op", unary.Operator switch
{
PolicyUnaryOperator.Not => "not",
_ => unary.Operator.ToString().ToLowerInvariant(),
});
writer.WritePropertyName("operand");
WriteExpression(writer, unary.Operand);
writer.WriteEndObject();
break;
case PolicyBinaryExpression binary:
writer.WriteStartObject();
writer.WriteString("type", "binary");
writer.WriteString("op", GetBinaryOperator(binary.Operator));
writer.WritePropertyName("left");
WriteExpression(writer, binary.Left);
writer.WritePropertyName("right");
WriteExpression(writer, binary.Right);
writer.WriteEndObject();
break;
default:
writer.WriteStartObject();
writer.WriteString("type", "unknown");
writer.WriteEndObject();
break;
}
}
private static string GetBinaryOperator(PolicyBinaryOperator op) => op switch
{
PolicyBinaryOperator.And => "and",
PolicyBinaryOperator.Or => "or",
PolicyBinaryOperator.Equal => "eq",
PolicyBinaryOperator.NotEqual => "neq",
PolicyBinaryOperator.LessThan => "lt",
PolicyBinaryOperator.LessThanOrEqual => "lte",
PolicyBinaryOperator.GreaterThan => "gt",
PolicyBinaryOperator.GreaterThanOrEqual => "gte",
PolicyBinaryOperator.In => "in",
PolicyBinaryOperator.NotIn => "not_in",
_ => op.ToString().ToLowerInvariant(),
};
private static void WriteLiteralDictionary(Utf8JsonWriter writer, ImmutableSortedDictionary<string, PolicyIrLiteral> dictionary)
{
writer.WriteStartObject();
foreach (var kvp in dictionary)
{
writer.WritePropertyName(kvp.Key);
WriteLiteral(writer, kvp.Value);
}
writer.WriteEndObject();
}
private static void WriteLiteral(Utf8JsonWriter writer, PolicyIrLiteral literal)
{
switch (literal)
{
case PolicyIrStringLiteral s:
writer.WriteStringValue(s.Value);
break;
case PolicyIrNumberLiteral n:
writer.WriteNumberValue(n.Value);
break;
case PolicyIrBooleanLiteral b:
writer.WriteBooleanValue(b.Value);
break;
case PolicyIrListLiteral list:
writer.WriteStartArray();
foreach (var item in list.Items)
{
WriteLiteral(writer, item);
}
writer.WriteEndArray();
break;
default:
writer.WriteNullValue();
break;
}
}
private static void WriteLiteralValue(Utf8JsonWriter writer, object? value)
{
switch (value)
{
case null:
writer.WriteNullValue();
break;
case string s:
writer.WriteStringValue(s);
break;
case bool b:
writer.WriteBooleanValue(b);
break;
case decimal dec:
writer.WriteNumberValue(dec);
break;
case double dbl:
writer.WriteNumberValue(dbl);
break;
case int i:
writer.WriteNumberValue(i);
break;
default:
writer.WriteStringValue(value.ToString());
break;
}
}
}

View File

@@ -0,0 +1,678 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using StellaOps.Policy;
namespace StellaOps.Policy.Engine.Compilation;
internal sealed class PolicyParser
{
private readonly ImmutableArray<DslToken> tokens;
private readonly List<PolicyIssue> diagnostics = new();
private int position;
private PolicyParser(ImmutableArray<DslToken> tokens)
{
this.tokens = tokens;
}
public static PolicyParseResult Parse(string source)
{
if (source is null)
{
throw new ArgumentNullException(nameof(source));
}
var tokenization = DslTokenizer.Tokenize(source);
var parser = new PolicyParser(tokenization.Tokens);
var document = parser.ParseDocument();
var allDiagnostics = tokenization.Diagnostics.AddRange(parser.diagnostics).ToImmutableArray();
return new PolicyParseResult(document, allDiagnostics);
}
private PolicyDocumentNode? ParseDocument()
{
if (!Match(TokenKind.KeywordPolicy))
{
diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.MissingPolicyHeader, "Expected 'policy' declaration.", "policy"));
return null;
}
var nameToken = Consume(TokenKind.StringLiteral, "Policy name must be a string literal.", "policy.name");
var name = nameToken.Value as string ?? nameToken.Text;
Consume(TokenKind.KeywordSyntax, "Expected 'syntax' declaration.", "policy.syntax");
var syntaxToken = Consume(TokenKind.StringLiteral, "Policy syntax must be a string literal.", "policy.syntax.value");
var syntax = syntaxToken.Value as string ?? syntaxToken.Text;
Consume(TokenKind.LeftBrace, "Expected '{' to start policy body.", "policy.body");
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, PolicyLiteralValue>(StringComparer.Ordinal);
var settingsBuilder = ImmutableDictionary.CreateBuilder<string, PolicyLiteralValue>(StringComparer.Ordinal);
var profiles = ImmutableArray.CreateBuilder<PolicyProfileNode>();
var rules = ImmutableArray.CreateBuilder<PolicyRuleNode>();
while (!Check(TokenKind.RightBrace) && !IsAtEnd)
{
if (Match(TokenKind.KeywordMetadata))
{
foreach (var kvp in ParseKeyValueBlock("policy.metadata"))
{
metadataBuilder[kvp.Key] = kvp.Value;
}
continue;
}
if (Match(TokenKind.KeywordSettings))
{
foreach (var kvp in ParseKeyValueBlock("policy.settings"))
{
settingsBuilder[kvp.Key] = kvp.Value;
}
continue;
}
if (Match(TokenKind.KeywordProfile))
{
var profile = ParseProfile();
if (profile is not null)
{
profiles.Add(profile);
}
continue;
}
if (Match(TokenKind.KeywordRule))
{
var rule = ParseRule();
if (rule is not null)
{
rules.Add(rule);
}
continue;
}
diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.UnexpectedSection, $"Unexpected token '{Current.Text}' in policy body.", "policy.body"));
Advance();
}
var close = Consume(TokenKind.RightBrace, "Expected '}' to close policy definition.", "policy");
if (!string.Equals(syntax, "stella-dsl@1", StringComparison.Ordinal))
{
diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.UnsupportedSyntaxVersion, $"Unsupported syntax '{syntax}'.", "policy.syntax"));
}
var span = new SourceSpan(tokens[0].Span.Start, close.Span.End);
return new PolicyDocumentNode(
name,
syntax,
metadataBuilder.ToImmutable(),
profiles.ToImmutable(),
settingsBuilder.ToImmutable(),
rules.ToImmutable(),
span);
}
private PolicyProfileNode? ParseProfile()
{
var nameToken = Consume(TokenKind.Identifier, "Profile requires a name.", "policy.profile");
var name = nameToken.Text;
Consume(TokenKind.LeftBrace, "Expected '{' after profile declaration.", $"policy.profile.{name}");
var start = nameToken.Span.Start;
var depth = 1;
while (depth > 0 && !IsAtEnd)
{
if (Match(TokenKind.LeftBrace))
{
depth++;
}
else if (Match(TokenKind.RightBrace))
{
depth--;
}
else
{
Advance();
}
}
var close = Previous;
return new PolicyProfileNode(
name,
ImmutableArray<PolicyProfileItemNode>.Empty,
new SourceSpan(start, close.Span.End));
}
private PolicyRuleNode? ParseRule()
{
var nameToken = Consume(TokenKind.Identifier, "Rule requires a name.", "policy.rule");
var name = nameToken.Text;
var priority = 0;
if (Match(TokenKind.KeywordPriority))
{
var priorityToken = Consume(TokenKind.NumberLiteral, "Priority must be numeric.", $"policy.rule.{name}");
if (priorityToken.Value is decimal dec)
{
priority = (int)Math.Round(dec, MidpointRounding.AwayFromZero);
}
}
Consume(TokenKind.LeftBrace, "Expected '{' to start rule.", $"policy.rule.{name}");
Consume(TokenKind.KeywordWhen, "Rule requires a 'when' clause.", $"policy.rule.{name}");
var when = ParseExpression();
Consume(TokenKind.KeywordThen, "Rule requires a 'then' clause.", $"policy.rule.{name}");
var thenActions = ParseActions(name, "then");
var elseActions = ImmutableArray<PolicyActionNode>.Empty;
if (Match(TokenKind.KeywordElse))
{
elseActions = ParseActions(name, "else");
}
string? because = null;
if (Match(TokenKind.KeywordBecause))
{
var becauseToken = Consume(TokenKind.StringLiteral, "Because clause must be string.", $"policy.rule.{name}.because");
because = becauseToken.Value as string ?? becauseToken.Text;
}
var close = Consume(TokenKind.RightBrace, "Expected '}' to close rule.", $"policy.rule.{name}");
if (because is null)
{
diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.MissingBecauseClause, $"Rule '{name}' missing 'because' clause.", $"policy.rule.{name}"));
}
return new PolicyRuleNode(name, priority, when, thenActions, elseActions, because, new SourceSpan(nameToken.Span.Start, close.Span.End));
}
private ImmutableArray<PolicyActionNode> ParseActions(string ruleName, string clause)
{
var actions = ImmutableArray.CreateBuilder<PolicyActionNode>();
while (!Check(TokenKind.RightBrace) && !Check(TokenKind.KeywordElse) && !Check(TokenKind.KeywordBecause) && !IsAtEnd)
{
if (Check(TokenKind.Identifier))
{
actions.Add(ParseAssignmentAction(ruleName, clause));
continue;
}
if (Match(TokenKind.KeywordAnnotate))
{
actions.Add(ParseAnnotateAction(ruleName, clause));
continue;
}
if (Match(TokenKind.KeywordWarn))
{
actions.Add(ParseWarnAction());
continue;
}
if (Match(TokenKind.KeywordEscalate))
{
actions.Add(ParseEscalateAction());
continue;
}
if (Match(TokenKind.KeywordRequireVex))
{
actions.Add(ParseRequireVexAction(ruleName, clause));
continue;
}
if (Match(TokenKind.KeywordIgnore))
{
actions.Add(ParseIgnoreAction(ruleName, clause));
continue;
}
if (Match(TokenKind.KeywordDefer))
{
actions.Add(ParseDeferAction(ruleName, clause));
continue;
}
diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.InvalidAction, $"Unexpected token '{Current.Text}' in {clause} actions.", $"policy.rule.{ruleName}.{clause}"));
Advance();
}
return actions.ToImmutable();
}
private PolicyActionNode ParseAssignmentAction(string ruleName, string clause)
{
var segments = ImmutableArray.CreateBuilder<string>();
var first = Consume(TokenKind.Identifier, "Assignment target must start with identifier.", $"policy.rule.{ruleName}.{clause}");
segments.Add(first.Text);
while (Match(TokenKind.Dot))
{
segments.Add(Consume(TokenKind.Identifier, "Expected identifier after '.'.", $"policy.rule.{ruleName}.{clause}").Text);
}
Consume(TokenKind.Define, "Expected ':=' in action.", $"policy.rule.{ruleName}.{clause}");
var value = ParseExpression();
Match(TokenKind.Semicolon);
return new PolicyAssignmentActionNode(new PolicyReference(segments.ToImmutable(), new SourceSpan(first.Span.Start, value.Span.End)), value, new SourceSpan(first.Span.Start, value.Span.End));
}
private PolicyActionNode ParseAnnotateAction(string ruleName, string clause)
{
var reference = ParseReference($"policy.rule.{ruleName}.{clause}.annotate");
Consume(TokenKind.Define, "Expected ':=' in annotate action.", $"policy.rule.{ruleName}.{clause}.annotate");
var value = ParseExpression();
Match(TokenKind.Semicolon);
return new PolicyAnnotateActionNode(reference, value, new SourceSpan(reference.Span.Start, value.Span.End));
}
private PolicyActionNode ParseWarnAction()
{
PolicyExpression? message = null;
if (Match(TokenKind.KeywordMessage))
{
message = ParseExpression();
}
Match(TokenKind.Semicolon);
var span = message?.Span ?? Previous.Span;
return new PolicyWarnActionNode(message, span);
}
private PolicyActionNode ParseEscalateAction()
{
PolicyExpression? to = null;
PolicyExpression? when = null;
if (Match(TokenKind.KeywordTo))
{
to = ParseExpression();
}
if (Match(TokenKind.KeywordWhen))
{
when = ParseExpression();
}
Match(TokenKind.Semicolon);
var end = when?.Span.End ?? to?.Span.End ?? Previous.Span.End;
return new PolicyEscalateActionNode(to, when, new SourceSpan(Previous.Span.Start, end));
}
private PolicyActionNode ParseRequireVexAction(string ruleName, string clause)
{
Consume(TokenKind.LeftBrace, "Expected '{' after requireVex.", $"policy.rule.{ruleName}.{clause}.requireVex");
var builder = ImmutableDictionary.CreateBuilder<string, PolicyExpression>(StringComparer.Ordinal);
while (!Check(TokenKind.RightBrace) && !IsAtEnd)
{
var key = Consume(TokenKind.Identifier, "requireVex key must be identifier.", $"policy.rule.{ruleName}.{clause}.requireVex").Text;
Consume(TokenKind.Assign, "Expected '=' in requireVex condition.", $"policy.rule.{ruleName}.{clause}.requireVex");
builder[key] = ParseExpression();
Match(TokenKind.Comma);
}
var close = Consume(TokenKind.RightBrace, "Expected '}' to close requireVex block.", $"policy.rule.{ruleName}.{clause}.requireVex");
Match(TokenKind.Semicolon);
return new PolicyRequireVexActionNode(builder.ToImmutable(), new SourceSpan(close.Span.Start, close.Span.End));
}
private PolicyActionNode ParseIgnoreAction(string ruleName, string clause)
{
PolicyExpression? until = null;
string? because = null;
if (Match(TokenKind.KeywordUntil))
{
until = ParseExpression();
}
if (Match(TokenKind.KeywordBecause))
{
var becauseToken = Consume(TokenKind.StringLiteral, "Ignore 'because' must be string.", $"policy.rule.{ruleName}.{clause}.ignore");
because = becauseToken.Value as string ?? becauseToken.Text;
}
Match(TokenKind.Semicolon);
return new PolicyIgnoreActionNode(until, because, new SourceSpan(Previous.Span.Start, (until?.Span.End ?? Previous.Span.End)));
}
private PolicyActionNode ParseDeferAction(string ruleName, string clause)
{
PolicyExpression? until = null;
if (Match(TokenKind.KeywordUntil))
{
until = ParseExpression();
}
Match(TokenKind.Semicolon);
return new PolicyDeferActionNode(until, new SourceSpan(Previous.Span.Start, (until?.Span.End ?? Previous.Span.End)));
}
private PolicyReference ParseReference(string path)
{
var segments = ImmutableArray.CreateBuilder<string>();
var first = Consume(TokenKind.Identifier, "Expected identifier.", path);
segments.Add(first.Text);
while (Match(TokenKind.Dot))
{
segments.Add(Consume(TokenKind.Identifier, "Expected identifier after '.'.", path).Text);
}
return new PolicyReference(segments.ToImmutable(), first.Span);
}
private Dictionary<string, PolicyLiteralValue> ParseKeyValueBlock(string path)
{
Consume(TokenKind.LeftBrace, "Expected '{'.", path);
var entries = new Dictionary<string, PolicyLiteralValue>(StringComparer.Ordinal);
while (!Check(TokenKind.RightBrace) && !IsAtEnd)
{
var key = Consume(TokenKind.Identifier, "Expected identifier.", path).Text;
Consume(TokenKind.Assign, "Expected '='.", path);
entries[key] = ParseLiteralValue(path);
Match(TokenKind.Semicolon);
}
Consume(TokenKind.RightBrace, "Expected '}'.", path);
return entries;
}
private PolicyLiteralValue ParseLiteralValue(string path)
{
if (Match(TokenKind.StringLiteral))
{
return new PolicyStringLiteral(Previous.Value as string ?? Previous.Text, Previous.Span);
}
if (Match(TokenKind.NumberLiteral))
{
return new PolicyNumberLiteral(Previous.Value is decimal dec ? dec : 0m, Previous.Span);
}
if (Match(TokenKind.BooleanLiteral))
{
return new PolicyBooleanLiteral(Previous.Value is bool b && b, Previous.Span);
}
if (Match(TokenKind.LeftBracket))
{
var start = Previous.Span.Start;
var items = ImmutableArray.CreateBuilder<PolicyLiteralValue>();
while (!Check(TokenKind.RightBracket) && !IsAtEnd)
{
items.Add(ParseLiteralValue(path));
Match(TokenKind.Comma);
}
var close = Consume(TokenKind.RightBracket, "Expected ']' in list literal.", path);
return new PolicyListLiteral(items.ToImmutable(), new SourceSpan(start, close.Span.End));
}
diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.InvalidLiteral, "Invalid literal.", path));
return new PolicyStringLiteral(string.Empty, Current.Span);
}
private PolicyExpression ParseExpression() => ParseOr();
private PolicyExpression ParseOr()
{
var expr = ParseAnd();
while (Match(TokenKind.KeywordOr))
{
var right = ParseAnd();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.Or, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
return expr;
}
private PolicyExpression ParseAnd()
{
var expr = ParseEquality();
while (Match(TokenKind.KeywordAnd))
{
var right = ParseEquality();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.And, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
return expr;
}
private PolicyExpression ParseEquality()
{
var expr = ParseUnary();
while (true)
{
if (Match(TokenKind.EqualEqual))
{
var right = ParseUnary();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.Equal, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
else if (Match(TokenKind.NotEqual))
{
var right = ParseUnary();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.NotEqual, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
else if (Match(TokenKind.KeywordIn))
{
var right = ParseUnary();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.In, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
else if (Match(TokenKind.KeywordNot))
{
if (Match(TokenKind.KeywordIn))
{
var right = ParseUnary();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.NotIn, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
else
{
diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.UnexpectedToken, "Expected 'in' after 'not'.", "expression.not"));
}
}
else if (Match(TokenKind.LessThan))
{
var right = ParseUnary();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.LessThan, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
else if (Match(TokenKind.LessThanOrEqual))
{
var right = ParseUnary();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.LessThanOrEqual, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
else if (Match(TokenKind.GreaterThan))
{
var right = ParseUnary();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.GreaterThan, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
else if (Match(TokenKind.GreaterThanOrEqual))
{
var right = ParseUnary();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.GreaterThanOrEqual, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
else
{
break;
}
}
return expr;
}
private PolicyExpression ParseUnary()
{
if (Match(TokenKind.KeywordNot))
{
var operand = ParseUnary();
return new PolicyUnaryExpression(PolicyUnaryOperator.Not, operand, new SourceSpan(Previous.Span.Start, operand.Span.End));
}
return ParsePrimary();
}
private PolicyExpression ParsePrimary()
{
if (Match(TokenKind.StringLiteral))
{
return new PolicyLiteralExpression(Previous.Value as string ?? Previous.Text, Previous.Span);
}
if (Match(TokenKind.NumberLiteral))
{
return new PolicyLiteralExpression(Previous.Value ?? 0m, Previous.Span);
}
if (Match(TokenKind.BooleanLiteral))
{
return new PolicyLiteralExpression(Previous.Value ?? false, Previous.Span);
}
if (Match(TokenKind.LeftBracket))
{
var start = Previous.Span.Start;
var items = ImmutableArray.CreateBuilder<PolicyExpression>();
while (!Check(TokenKind.RightBracket) && !IsAtEnd)
{
items.Add(ParseExpression());
Match(TokenKind.Comma);
}
var close = Consume(TokenKind.RightBracket, "Expected ']' to close list expression.", "expression.list");
return new PolicyListExpression(items.ToImmutable(), new SourceSpan(start, close.Span.End));
}
if (Match(TokenKind.LeftParen))
{
var expr = ParseExpression();
Consume(TokenKind.RightParen, "Expected ')' to close grouped expression.", "expression.group");
return expr;
}
if (Match(TokenKind.Identifier))
{
return ParseIdentifierExpression(Previous);
}
if (Match(TokenKind.KeywordEnv))
{
return ParseIdentifierExpression(Previous);
}
diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.UnexpectedToken, $"Unexpected token '{Current.Text}' in expression.", "expression"));
var bad = Advance();
return new PolicyLiteralExpression(null, bad.Span);
}
private PolicyExpression ParseIdentifierExpression(DslToken identifier)
{
PolicyExpression expr = new PolicyIdentifierExpression(identifier.Text, identifier.Span);
while (true)
{
if (Match(TokenKind.Dot))
{
var member = Consume(TokenKind.Identifier, "Expected identifier after '.'.", "expression.member");
expr = new PolicyMemberAccessExpression(expr, member.Text, new SourceSpan(expr.Span.Start, member.Span.End));
continue;
}
if (Match(TokenKind.LeftParen))
{
var args = ImmutableArray.CreateBuilder<PolicyExpression>();
if (!Check(TokenKind.RightParen))
{
do
{
args.Add(ParseExpression());
}
while (Match(TokenKind.Comma));
}
var close = Consume(TokenKind.RightParen, "Expected ')' to close invocation.", "expression.call");
expr = new PolicyInvocationExpression(expr, args.ToImmutable(), new SourceSpan(expr.Span.Start, close.Span.End));
continue;
}
if (Match(TokenKind.LeftBracket))
{
var indexExpr = ParseExpression();
var close = Consume(TokenKind.RightBracket, "Expected ']' to close indexer.", "expression.indexer");
expr = new PolicyIndexerExpression(expr, indexExpr, new SourceSpan(expr.Span.Start, close.Span.End));
continue;
}
break;
}
return expr;
}
private bool Match(TokenKind kind)
{
if (Check(kind))
{
Advance();
return true;
}
return false;
}
private bool Check(TokenKind kind) => !IsAtEnd && Current.Kind == kind;
private DslToken Consume(TokenKind kind, string message, string path)
{
if (Check(kind))
{
return Advance();
}
diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.UnexpectedToken, message, path));
return Advance();
}
private void SkipBlock()
{
var depth = 1;
while (depth > 0 && !IsAtEnd)
{
if (Match(TokenKind.LeftBrace))
{
depth++;
}
else if (Match(TokenKind.RightBrace))
{
depth--;
}
else
{
Advance();
}
}
}
private DslToken Advance()
{
if (!IsAtEnd)
{
position++;
}
return tokens[position - 1];
}
private bool IsAtEnd => Current.Kind == TokenKind.EndOfFile;
private DslToken Current => tokens[position];
private DslToken Previous => tokens[position - 1];
}
internal readonly record struct PolicyParseResult(
PolicyDocumentNode? Document,
ImmutableArray<PolicyIssue> Diagnostics);

View File

@@ -0,0 +1,141 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.Engine.Compilation;
public abstract record SyntaxNode(SourceSpan Span);
public sealed record PolicyDocumentNode(
string Name,
string Syntax,
ImmutableDictionary<string, PolicyLiteralValue> Metadata,
ImmutableArray<PolicyProfileNode> Profiles,
ImmutableDictionary<string, PolicyLiteralValue> Settings,
ImmutableArray<PolicyRuleNode> Rules,
SourceSpan Span) : SyntaxNode(Span);
public sealed record PolicyProfileNode(
string Name,
ImmutableArray<PolicyProfileItemNode> Items,
SourceSpan Span) : SyntaxNode(Span);
public abstract record PolicyProfileItemNode(SourceSpan Span);
public sealed record PolicyProfileMapNode(
string Name,
ImmutableArray<PolicyProfileMapEntryNode> Entries,
SourceSpan Span) : PolicyProfileItemNode(Span);
public sealed record PolicyProfileMapEntryNode(
string Source,
decimal Weight,
SourceSpan Span) : SyntaxNode(Span);
public sealed record PolicyProfileEnvNode(
string Name,
ImmutableArray<PolicyProfileEnvEntryNode> Entries,
SourceSpan Span) : PolicyProfileItemNode(Span);
public sealed record PolicyProfileEnvEntryNode(
PolicyExpression Condition,
decimal Weight,
SourceSpan Span) : SyntaxNode(Span);
public sealed record PolicyProfileScalarNode(
string Name,
PolicyLiteralValue Value,
SourceSpan Span) : PolicyProfileItemNode(Span);
public sealed record PolicyRuleNode(
string Name,
int Priority,
PolicyExpression When,
ImmutableArray<PolicyActionNode> ThenActions,
ImmutableArray<PolicyActionNode> ElseActions,
string? Because,
SourceSpan Span) : SyntaxNode(Span);
public abstract record PolicyActionNode(SourceSpan Span);
public sealed record PolicyAssignmentActionNode(
PolicyReference Target,
PolicyExpression Value,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyAnnotateActionNode(
PolicyReference Target,
PolicyExpression Value,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyIgnoreActionNode(
PolicyExpression? Until,
string? Because,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyEscalateActionNode(
PolicyExpression? To,
PolicyExpression? When,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyRequireVexActionNode(
ImmutableDictionary<string, PolicyExpression> Conditions,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyWarnActionNode(
PolicyExpression? Message,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyDeferActionNode(
PolicyExpression? Until,
SourceSpan Span) : PolicyActionNode(Span);
public abstract record PolicyExpression(SourceSpan Span);
public sealed record PolicyLiteralExpression(object? Value, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyListExpression(ImmutableArray<PolicyExpression> Items, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyIdentifierExpression(string Name, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyMemberAccessExpression(PolicyExpression Target, string Member, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyInvocationExpression(PolicyExpression Target, ImmutableArray<PolicyExpression> Arguments, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyIndexerExpression(PolicyExpression Target, PolicyExpression Index, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyUnaryExpression(PolicyUnaryOperator Operator, PolicyExpression Operand, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyBinaryExpression(PolicyExpression Left, PolicyBinaryOperator Operator, PolicyExpression Right, SourceSpan Span) : PolicyExpression(Span);
public enum PolicyUnaryOperator
{
Not,
}
public enum PolicyBinaryOperator
{
And,
Or,
Equal,
NotEqual,
LessThan,
LessThanOrEqual,
GreaterThan,
GreaterThanOrEqual,
In,
NotIn,
}
public sealed record PolicyReference(ImmutableArray<string> Segments, SourceSpan Span)
{
public override string ToString() => string.Join(".", Segments);
}
public abstract record PolicyLiteralValue(SourceSpan Span);
public sealed record PolicyStringLiteral(string Value, SourceSpan Span) : PolicyLiteralValue(Span);
public sealed record PolicyNumberLiteral(decimal Value, SourceSpan Span) : PolicyLiteralValue(Span);
public sealed record PolicyBooleanLiteral(bool Value, SourceSpan Span) : PolicyLiteralValue(Span);
public sealed record PolicyListLiteral(ImmutableArray<PolicyLiteralValue> Items, SourceSpan Span) : PolicyLiteralValue(Span);

View File

@@ -0,0 +1,101 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
namespace StellaOps.Policy.Engine.Domain;
internal sealed class PolicyPackRecord
{
private readonly ConcurrentDictionary<int, PolicyRevisionRecord> revisions = new();
public PolicyPackRecord(string packId, string? displayName, DateTimeOffset createdAt)
{
PackId = packId ?? throw new ArgumentNullException(nameof(packId));
DisplayName = displayName;
CreatedAt = createdAt;
}
public string PackId { get; }
public string? DisplayName { get; }
public DateTimeOffset CreatedAt { get; }
public ImmutableArray<PolicyRevisionRecord> GetRevisions()
=> revisions.Values
.OrderBy(r => r.Version)
.ToImmutableArray();
public PolicyRevisionRecord GetOrAddRevision(int version, Func<int, PolicyRevisionRecord> factory)
=> revisions.GetOrAdd(version, factory);
public bool TryGetRevision(int version, out PolicyRevisionRecord revision)
=> revisions.TryGetValue(version, out revision!);
public int GetNextVersion()
=> 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;
CreatedAt = createdAt;
}
public int Version { get; }
public bool RequiresTwoPersonApproval { get; }
public PolicyRevisionStatus Status { get; private set; }
public DateTimeOffset CreatedAt { get; }
public DateTimeOffset? ActivatedAt { get; private set; }
public ImmutableArray<PolicyActivationApproval> Approvals
=> approvals.Values
.OrderBy(approval => approval.ApprovedAt)
.ToImmutableArray();
public void SetStatus(PolicyRevisionStatus status, DateTimeOffset timestamp)
{
Status = status;
if (status == PolicyRevisionStatus.Active)
{
ActivatedAt = timestamp;
}
}
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,
Approved,
Active
}
internal sealed record PolicyActivationApproval(string ActorId, DateTimeOffset ApprovedAt, string? Comment);
internal enum PolicyActivationApprovalStatus
{
Pending,
ThresholdReached,
Duplicate
}

View File

@@ -0,0 +1,107 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Services;
using System.Collections.Immutable;
namespace StellaOps.Policy.Engine.Endpoints;
internal static class PolicyCompilationEndpoints
{
private const string CompileRoute = "/api/policy/policies/{policyId}/versions/{version}:compile";
public static IEndpointRouteBuilder MapPolicyCompilation(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPost(CompileRoute, CompilePolicy)
.WithName("CompilePolicy")
.WithSummary("Compile and lint a policy DSL document.")
.WithDescription("Compiles a stella-dsl@1 policy document and returns deterministic digest and statistics.")
.Produces<PolicyCompileResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.RequireAuthorization(); // scopes enforced by policy middleware.
return endpoints;
}
private static IResult CompilePolicy(
[FromRoute] string policyId,
[FromRoute] int version,
[FromBody] PolicyCompileRequest request,
PolicyCompilationService compilationService)
{
if (request is null)
{
return Results.BadRequest(BuildProblem("ERR_POL_001", "Request body missing.", policyId, version));
}
var result = compilationService.Compile(request);
if (!result.Success)
{
return Results.BadRequest(BuildProblem("ERR_POL_001", "Policy compilation failed.", policyId, version, result.Diagnostics));
}
var response = new PolicyCompileResponse(
result.Digest!,
result.Statistics ?? new PolicyCompilationStatistics(0, ImmutableDictionary<string, int>.Empty),
ConvertDiagnostics(result.Diagnostics));
return Results.Ok(response);
}
private static PolicyProblemDetails BuildProblem(string code, string message, string policyId, int version, ImmutableArray<PolicyIssue>? diagnostics = null)
{
var problem = new PolicyProblemDetails
{
Code = code,
Title = "Policy compilation error",
Detail = message,
PolicyId = policyId,
PolicyVersion = version
};
if (diagnostics is { Length: > 0 } diag)
{
problem.Diagnostics = diag;
}
return problem;
}
private static ImmutableArray<PolicyDiagnosticDto> ConvertDiagnostics(ImmutableArray<PolicyIssue> issues)
{
if (issues.IsDefaultOrEmpty)
{
return ImmutableArray<PolicyDiagnosticDto>.Empty;
}
var builder = ImmutableArray.CreateBuilder<PolicyDiagnosticDto>(issues.Length);
foreach (var issue in issues)
{
if (issue.Severity != PolicyIssueSeverity.Warning)
{
continue;
}
builder.Add(new PolicyDiagnosticDto(issue.Code, issue.Message, issue.Path));
}
return builder.ToImmutable();
}
private sealed class PolicyProblemDetails : ProblemDetails
{
public string Code { get; set; } = "ERR_POL_001";
public string? PolicyId { get; set; }
public int PolicyVersion { get; set; }
public ImmutableArray<PolicyIssue> Diagnostics { get; set; } = ImmutableArray<PolicyIssue>.Empty;
}
}
internal sealed record PolicyCompileResponse(
string Digest,
PolicyCompilationStatistics Statistics,
ImmutableArray<PolicyDiagnosticDto> Warnings);
internal sealed record PolicyDiagnosticDto(string Code, string Message, string Path);

View File

@@ -0,0 +1,267 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Services;
namespace StellaOps.Policy.Engine.Endpoints;
internal static class PolicyPackEndpoints
{
public static IEndpointRouteBuilder MapPolicyPacks(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/policy/packs")
.RequireAuthorization()
.WithTags("Policy Packs");
group.MapPost(string.Empty, CreatePack)
.WithName("CreatePolicyPack")
.WithSummary("Create a new policy pack container.")
.Produces<PolicyPackDto>(StatusCodes.Status201Created);
group.MapGet(string.Empty, ListPacks)
.WithName("ListPolicyPacks")
.WithSummary("List policy packs for the current tenant.")
.Produces<IReadOnlyList<PolicyPackSummaryDto>>(StatusCodes.Status200OK);
group.MapPost("/{packId}/revisions", CreateRevision)
.WithName("CreatePolicyRevision")
.WithSummary("Create or update policy revision metadata.")
.Produces<PolicyRevisionDto>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/{packId}/revisions/{version:int}:activate", ActivateRevision)
.WithName("ActivatePolicyRevision")
.WithSummary("Activate an approved policy revision, enforcing two-person approval when required.")
.Produces<PolicyRevisionActivationResponse>(StatusCodes.Status200OK)
.Produces<PolicyRevisionActivationResponse>(StatusCodes.Status202Accepted)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
return endpoints;
}
private static async Task<IResult> CreatePack(
HttpContext context,
[FromBody] CreatePolicyPackRequest request,
IPolicyPackRepository repository,
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 packId = string.IsNullOrWhiteSpace(request.PackId)
? $"pack-{Guid.NewGuid():n}"
: request.PackId.Trim();
var pack = await repository.CreateAsync(packId, request.DisplayName?.Trim(), cancellationToken).ConfigureAwait(false);
var dto = PolicyPackMapper.ToDto(pack);
return Results.Created($"/api/policy/packs/{dto.PackId}", dto);
}
private static async Task<IResult> ListPacks(
HttpContext context,
IPolicyPackRepository repository,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var packs = await repository.ListAsync(cancellationToken).ConfigureAwait(false);
var summaries = packs.Select(PolicyPackMapper.ToSummaryDto).ToArray();
return Results.Ok(summaries);
}
private static async Task<IResult> CreateRevision(
HttpContext context,
[FromRoute] string packId,
[FromBody] CreatePolicyRevisionRequest request,
IPolicyPackRepository repository,
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
});
}
if (request.InitialStatus is not (PolicyRevisionStatus.Draft or PolicyRevisionStatus.Approved))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid status",
Detail = "Only Draft or Approved statuses are supported for new revisions.",
Status = StatusCodes.Status400BadRequest
});
}
var revision = await repository.UpsertRevisionAsync(
packId,
request.Version ?? 0,
request.RequiresTwoPersonApproval,
request.InitialStatus,
cancellationToken).ConfigureAwait(false);
return Results.Created(
$"/api/policy/packs/{packId}/revisions/{revision.Version}",
PolicyPackMapper.ToDto(packId, revision));
}
private static async Task<IResult> ActivateRevision(
HttpContext context,
[FromRoute] string packId,
[FromRoute] int version,
[FromBody] ActivatePolicyRevisionRequest request,
IPolicyPackRepository repository,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate);
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 actorId = ResolveActorId(context);
if (actorId is null)
{
return Results.Problem("Actor identity required.", statusCode: StatusCodes.Status401Unauthorized);
}
var result = await repository.RecordActivationAsync(
packId,
version,
actorId,
DateTimeOffset.UtcNow,
request.Comment,
cancellationToken).ConfigureAwait(false);
return result.Status switch
{
PolicyActivationResultStatus.PackNotFound => Results.NotFound(new ProblemDetails
{
Title = "Policy pack not found",
Status = StatusCodes.Status404NotFound
}),
PolicyActivationResultStatus.RevisionNotFound => Results.NotFound(new ProblemDetails
{
Title = "Policy revision not found",
Status = StatusCodes.Status404NotFound
}),
PolicyActivationResultStatus.NotApproved => Results.BadRequest(new ProblemDetails
{
Title = "Revision not approved",
Detail = "Only approved revisions may be activated.",
Status = StatusCodes.Status400BadRequest
}),
PolicyActivationResultStatus.DuplicateApproval => Results.BadRequest(new ProblemDetails
{
Title = "Approval already recorded",
Detail = "This approver has already approved activation.",
Status = StatusCodes.Status400BadRequest
}),
PolicyActivationResultStatus.PendingSecondApproval => Results.Accepted(
$"/api/policy/packs/{packId}/revisions/{version}",
new PolicyRevisionActivationResponse("pending_second_approval", PolicyPackMapper.ToDto(packId, result.Revision!))),
PolicyActivationResultStatus.Activated => Results.Ok(new PolicyRevisionActivationResponse("activated", PolicyPackMapper.ToDto(packId, result.Revision!))),
PolicyActivationResultStatus.AlreadyActive => Results.Ok(new PolicyRevisionActivationResponse("already_active", PolicyPackMapper.ToDto(packId, result.Revision!))),
_ => Results.BadRequest(new ProblemDetails
{
Title = "Activation failed",
Detail = "Unknown activation result.",
Status = StatusCodes.Status400BadRequest
})
};
}
private static string? ResolveActorId(HttpContext context)
{
var user = context.User;
var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user?.FindFirst(ClaimTypes.Upn)?.Value
?? user?.FindFirst("sub")?.Value;
if (!string.IsNullOrWhiteSpace(actor))
{
return actor;
}
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) && !string.IsNullOrWhiteSpace(header))
{
return header.ToString();
}
return null;
}
}
internal static class PolicyPackMapper
{
public static PolicyPackDto ToDto(PolicyPackRecord record)
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => ToDto(record.PackId, r)).ToArray());
public static PolicyPackSummaryDto ToSummaryDto(PolicyPackRecord record)
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => r.Version).ToArray());
public static PolicyRevisionDto ToDto(string packId, PolicyRevisionRecord revision)
=> new(
packId,
revision.Version,
revision.Status.ToString(),
revision.RequiresTwoPersonApproval,
revision.CreatedAt,
revision.ActivatedAt,
revision.Approvals.Select(a => new PolicyActivationApprovalDto(a.ActorId, a.ApprovedAt, a.Comment)).ToArray());
}
internal sealed record CreatePolicyPackRequest(string? PackId, string? DisplayName);
internal sealed record PolicyPackDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<PolicyRevisionDto> Revisions);
internal sealed record PolicyPackSummaryDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<int> Versions);
internal sealed record CreatePolicyRevisionRequest(int? Version, bool RequiresTwoPersonApproval, PolicyRevisionStatus InitialStatus = PolicyRevisionStatus.Approved);
internal sealed record PolicyRevisionDto(string PackId, int Version, string Status, bool RequiresTwoPersonApproval, DateTimeOffset CreatedAt, DateTimeOffset? ActivatedAt, IReadOnlyList<PolicyActivationApprovalDto> Approvals);
internal sealed record PolicyActivationApprovalDto(string ActorId, DateTimeOffset ApprovedAt, string? Comment);
internal sealed record ActivatePolicyRevisionRequest(string? Comment);
internal sealed record PolicyRevisionActivationResponse(string Status, PolicyRevisionDto Revision);

View File

@@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
namespace StellaOps.Policy.Engine.Evaluation;
internal sealed record PolicyEvaluationRequest(
PolicyIrDocument Document,
PolicyEvaluationContext Context);
internal sealed record PolicyEvaluationContext(
PolicyEvaluationSeverity Severity,
PolicyEvaluationEnvironment Environment,
PolicyEvaluationAdvisory Advisory,
PolicyEvaluationVexEvidence Vex,
PolicyEvaluationSbom Sbom,
PolicyEvaluationExceptions Exceptions);
internal sealed record PolicyEvaluationSeverity(string Normalized, decimal? Score = null);
internal sealed record PolicyEvaluationEnvironment(
ImmutableDictionary<string, string> Properties)
{
public string? Get(string key) => Properties.TryGetValue(key, out var value) ? value : null;
}
internal sealed record PolicyEvaluationAdvisory(
string Source,
ImmutableDictionary<string, string> Metadata);
internal sealed record PolicyEvaluationVexEvidence(
ImmutableArray<PolicyEvaluationVexStatement> Statements)
{
public static readonly PolicyEvaluationVexEvidence Empty = new(ImmutableArray<PolicyEvaluationVexStatement>.Empty);
}
internal sealed record PolicyEvaluationVexStatement(
string Status,
string Justification,
string StatementId,
DateTimeOffset? Timestamp = null);
internal sealed record PolicyEvaluationSbom(ImmutableHashSet<string> Tags)
{
public bool HasTag(string tag) => Tags.Contains(tag);
}
internal sealed record PolicyEvaluationResult(
bool Matched,
string Status,
string? Severity,
string? RuleName,
int? Priority,
ImmutableDictionary<string, string> Annotations,
ImmutableArray<string> Warnings,
PolicyExceptionApplication? AppliedException)
{
public static PolicyEvaluationResult CreateDefault(string? severity) => new(
Matched: false,
Status: "affected",
Severity: severity,
RuleName: null,
Priority: null,
Annotations: ImmutableDictionary<string, string>.Empty,
Warnings: ImmutableArray<string>.Empty,
AppliedException: null);
}
internal sealed record PolicyEvaluationExceptions(
ImmutableDictionary<string, PolicyExceptionEffect> Effects,
ImmutableArray<PolicyEvaluationExceptionInstance> Instances)
{
public static readonly PolicyEvaluationExceptions Empty = new(
ImmutableDictionary<string, PolicyExceptionEffect>.Empty,
ImmutableArray<PolicyEvaluationExceptionInstance>.Empty);
public bool IsEmpty => Instances.IsDefaultOrEmpty || Instances.Length == 0;
}
internal sealed record PolicyEvaluationExceptionInstance(
string Id,
string EffectId,
PolicyEvaluationExceptionScope Scope,
DateTimeOffset CreatedAt,
ImmutableDictionary<string, string> Metadata);
internal sealed record PolicyEvaluationExceptionScope(
ImmutableHashSet<string> RuleNames,
ImmutableHashSet<string> Severities,
ImmutableHashSet<string> Sources,
ImmutableHashSet<string> Tags)
{
public static PolicyEvaluationExceptionScope Empty { get; } = new(
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase));
public bool IsEmpty => RuleNames.Count == 0
&& Severities.Count == 0
&& Sources.Count == 0
&& Tags.Count == 0;
public static PolicyEvaluationExceptionScope Create(
IEnumerable<string>? ruleNames = null,
IEnumerable<string>? severities = null,
IEnumerable<string>? sources = null,
IEnumerable<string>? tags = null)
{
return new PolicyEvaluationExceptionScope(
Normalize(ruleNames),
Normalize(severities),
Normalize(sources),
Normalize(tags));
}
private static ImmutableHashSet<string> Normalize(IEnumerable<string>? values)
{
if (values is null)
{
return ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
}
return values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
}
}
internal sealed record PolicyExceptionApplication(
string ExceptionId,
string EffectId,
PolicyExceptionEffectType EffectType,
string OriginalStatus,
string? OriginalSeverity,
string AppliedStatus,
string? AppliedSeverity,
ImmutableDictionary<string, string> Metadata);

View File

@@ -0,0 +1,420 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
namespace StellaOps.Policy.Engine.Evaluation;
/// <summary>
/// Deterministically evaluates compiled policy IR against advisory/VEX/SBOM inputs.
/// </summary>
internal sealed class PolicyEvaluator
{
public PolicyEvaluationResult Evaluate(PolicyEvaluationRequest request)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
if (request.Document is null)
{
throw new ArgumentNullException(nameof(request.Document));
}
var evaluator = new PolicyExpressionEvaluator(request.Context);
var orderedRules = request.Document.Rules
.Select(static (rule, index) => new { rule, index })
.OrderBy(x => x.rule.Priority)
.ThenBy(x => x.index)
.ToImmutableArray();
foreach (var entry in orderedRules)
{
var rule = entry.rule;
if (!evaluator.EvaluateBoolean(rule.When))
{
continue;
}
var runtime = new PolicyRuntimeState(request.Context.Severity.Normalized);
foreach (var action in rule.ThenActions)
{
ApplyAction(rule.Name, action, evaluator, runtime);
}
if (runtime.Status is null)
{
runtime.Status = "affected";
}
var baseResult = new PolicyEvaluationResult(
Matched: true,
Status: runtime.Status,
Severity: runtime.Severity,
RuleName: rule.Name,
Priority: rule.Priority,
Annotations: runtime.Annotations.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
Warnings: runtime.Warnings.ToImmutableArray(),
AppliedException: null);
return ApplyExceptions(request, baseResult);
}
var defaultResult = PolicyEvaluationResult.CreateDefault(request.Context.Severity.Normalized);
return ApplyExceptions(request, defaultResult);
}
private static void ApplyAction(
string ruleName,
PolicyIrAction action,
PolicyExpressionEvaluator evaluator,
PolicyRuntimeState runtime)
{
switch (action)
{
case PolicyIrAssignmentAction assign:
ApplyAssignment(assign, evaluator, runtime);
break;
case PolicyIrAnnotateAction annotate:
ApplyAnnotate(annotate, evaluator, runtime);
break;
case PolicyIrWarnAction warn:
ApplyWarn(warn, evaluator, runtime);
break;
case PolicyIrEscalateAction escalate:
ApplyEscalate(escalate, evaluator, runtime);
break;
case PolicyIrRequireVexAction require:
var allSatisfied = true;
foreach (var condition in require.Conditions.Values)
{
if (!evaluator.EvaluateBoolean(condition))
{
allSatisfied = false;
break;
}
}
runtime.Status ??= allSatisfied ? "affected" : "suppressed";
break;
case PolicyIrIgnoreAction ignore:
runtime.Status = "ignored";
break;
case PolicyIrDeferAction defer:
runtime.Status = "deferred";
break;
default:
runtime.Warnings.Add($"Unhandled action '{action.GetType().Name}' in rule '{ruleName}'.");
break;
}
}
private static void ApplyAssignment(PolicyIrAssignmentAction assign, PolicyExpressionEvaluator evaluator, PolicyRuntimeState runtime)
{
var value = evaluator.Evaluate(assign.Value);
var stringValue = value.AsString();
if (assign.Target.Length == 0)
{
return;
}
var target = assign.Target[0];
switch (target)
{
case "status":
runtime.Status = stringValue ?? runtime.Status ?? "affected";
break;
case "severity":
runtime.Severity = stringValue;
break;
default:
runtime.Annotations[target] = stringValue ?? value.Raw?.ToString() ?? string.Empty;
break;
}
}
private static void ApplyAnnotate(PolicyIrAnnotateAction annotate, PolicyExpressionEvaluator evaluator, PolicyRuntimeState runtime)
{
var key = annotate.Target.Length > 0 ? annotate.Target[^1] : "annotation";
var value = evaluator.Evaluate(annotate.Value).AsString() ?? string.Empty;
runtime.Annotations[key] = value;
}
private static void ApplyWarn(PolicyIrWarnAction warn, PolicyExpressionEvaluator evaluator, PolicyRuntimeState runtime)
{
var message = warn.Message is null ? "" : evaluator.Evaluate(warn.Message).AsString();
if (!string.IsNullOrWhiteSpace(message))
{
runtime.Warnings.Add(message!);
}
else
{
runtime.Warnings.Add("Policy rule emitted a warning.");
}
runtime.Status ??= "warned";
}
private static void ApplyEscalate(PolicyIrEscalateAction escalate, PolicyExpressionEvaluator evaluator, PolicyRuntimeState runtime)
{
if (escalate.To is not null)
{
runtime.Severity = evaluator.Evaluate(escalate.To).AsString() ?? runtime.Severity;
}
if (escalate.When is not null && !evaluator.EvaluateBoolean(escalate.When))
{
return;
}
}
private sealed class PolicyRuntimeState
{
public PolicyRuntimeState(string? initialSeverity)
{
Severity = initialSeverity;
}
public string? Status { get; set; }
public string? Severity { get; set; }
public Dictionary<string, string> Annotations { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<string> Warnings { get; } = new();
}
private static PolicyEvaluationResult ApplyExceptions(PolicyEvaluationRequest request, PolicyEvaluationResult baseResult)
{
var exceptions = request.Context.Exceptions;
if (exceptions.IsEmpty)
{
return baseResult;
}
PolicyEvaluationExceptionInstance? winningInstance = null;
PolicyExceptionEffect? winningEffect = null;
var winningScore = -1;
foreach (var instance in exceptions.Instances)
{
if (!exceptions.Effects.TryGetValue(instance.EffectId, out var effect))
{
continue;
}
if (!MatchesScope(instance.Scope, request, baseResult))
{
continue;
}
var specificity = ComputeSpecificity(instance.Scope);
if (specificity < 0)
{
continue;
}
if (winningInstance is null
|| specificity > winningScore
|| (specificity == winningScore && instance.CreatedAt > winningInstance.CreatedAt)
|| (specificity == winningScore && instance.CreatedAt == winningInstance!.CreatedAt
&& string.CompareOrdinal(instance.Id, winningInstance.Id) < 0))
{
winningInstance = instance;
winningEffect = effect;
winningScore = specificity;
}
}
if (winningInstance is null || winningEffect is null)
{
return baseResult;
}
return ApplyExceptionEffect(baseResult, winningInstance, winningEffect);
}
private static bool MatchesScope(
PolicyEvaluationExceptionScope scope,
PolicyEvaluationRequest request,
PolicyEvaluationResult baseResult)
{
if (scope.RuleNames.Count > 0)
{
if (string.IsNullOrEmpty(baseResult.RuleName)
|| !scope.RuleNames.Contains(baseResult.RuleName))
{
return false;
}
}
if (scope.Severities.Count > 0)
{
var severity = request.Context.Severity.Normalized;
if (string.IsNullOrEmpty(severity)
|| !scope.Severities.Contains(severity))
{
return false;
}
}
if (scope.Sources.Count > 0)
{
var source = request.Context.Advisory.Source;
if (string.IsNullOrEmpty(source)
|| !scope.Sources.Contains(source))
{
return false;
}
}
if (scope.Tags.Count > 0)
{
var sbom = request.Context.Sbom;
var hasMatch = scope.Tags.Any(sbom.HasTag);
if (!hasMatch)
{
return false;
}
}
return true;
}
private static int ComputeSpecificity(PolicyEvaluationExceptionScope scope)
{
var score = 0;
if (scope.RuleNames.Count > 0)
{
score += 1_000 + scope.RuleNames.Count * 25;
}
if (scope.Severities.Count > 0)
{
score += 500 + scope.Severities.Count * 10;
}
if (scope.Sources.Count > 0)
{
score += 250 + scope.Sources.Count * 10;
}
if (scope.Tags.Count > 0)
{
score += 100 + scope.Tags.Count * 5;
}
return score;
}
private static PolicyEvaluationResult ApplyExceptionEffect(
PolicyEvaluationResult baseResult,
PolicyEvaluationExceptionInstance instance,
PolicyExceptionEffect effect)
{
var annotationsBuilder = baseResult.Annotations.ToBuilder();
annotationsBuilder["exception.id"] = instance.Id;
annotationsBuilder["exception.effectId"] = effect.Id;
annotationsBuilder["exception.effectType"] = effect.Effect.ToString();
if (!string.IsNullOrWhiteSpace(effect.Name))
{
annotationsBuilder["exception.effectName"] = effect.Name!;
}
if (!string.IsNullOrWhiteSpace(effect.RoutingTemplate))
{
annotationsBuilder["exception.routingTemplate"] = effect.RoutingTemplate!;
}
if (effect.MaxDurationDays is int durationDays)
{
annotationsBuilder["exception.maxDurationDays"] = durationDays.ToString(CultureInfo.InvariantCulture);
}
foreach (var pair in instance.Metadata)
{
annotationsBuilder[$"exception.meta.{pair.Key}"] = pair.Value;
}
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(effect.RoutingTemplate))
{
metadataBuilder["routingTemplate"] = effect.RoutingTemplate!;
}
if (effect.MaxDurationDays is int metadataDuration)
{
metadataBuilder["maxDurationDays"] = metadataDuration.ToString(CultureInfo.InvariantCulture);
}
if (!string.IsNullOrWhiteSpace(effect.RequiredControlId))
{
metadataBuilder["requiredControlId"] = effect.RequiredControlId!;
}
if (!string.IsNullOrWhiteSpace(effect.Name))
{
metadataBuilder["effectName"] = effect.Name!;
}
foreach (var pair in instance.Metadata)
{
metadataBuilder[pair.Key] = pair.Value;
}
var newStatus = baseResult.Status;
var newSeverity = baseResult.Severity;
var warnings = baseResult.Warnings;
switch (effect.Effect)
{
case PolicyExceptionEffectType.Suppress:
newStatus = "suppressed";
annotationsBuilder["exception.status"] = newStatus;
break;
case PolicyExceptionEffectType.Defer:
newStatus = "deferred";
annotationsBuilder["exception.status"] = newStatus;
break;
case PolicyExceptionEffectType.Downgrade:
if (effect.DowngradeSeverity is { } downgradeSeverity)
{
newSeverity = downgradeSeverity.ToString();
annotationsBuilder["exception.severity"] = newSeverity!;
}
break;
case PolicyExceptionEffectType.RequireControl:
if (!string.IsNullOrWhiteSpace(effect.RequiredControlId))
{
annotationsBuilder["exception.requiredControl"] = effect.RequiredControlId!;
warnings = warnings.Add($"Exception '{instance.Id}' requires control '{effect.RequiredControlId}'.");
}
break;
}
var application = new PolicyExceptionApplication(
ExceptionId: instance.Id,
EffectId: instance.EffectId,
EffectType: effect.Effect,
OriginalStatus: baseResult.Status,
OriginalSeverity: baseResult.Severity,
AppliedStatus: newStatus,
AppliedSeverity: newSeverity,
Metadata: metadataBuilder.ToImmutable());
return baseResult with
{
Status = newStatus,
Severity = newSeverity,
Annotations = annotationsBuilder.ToImmutable(),
Warnings = warnings,
AppliedException = application,
};
}
}

View File

@@ -0,0 +1,509 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using StellaOps.Policy.Engine.Compilation;
namespace StellaOps.Policy.Engine.Evaluation;
internal sealed class PolicyExpressionEvaluator
{
private static readonly IReadOnlyDictionary<string, decimal> SeverityOrder = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
{
["critical"] = 5m,
["high"] = 4m,
["medium"] = 3m,
["moderate"] = 3m,
["low"] = 2m,
["informational"] = 1m,
["info"] = 1m,
["none"] = 0m,
["unknown"] = -1m,
};
private readonly PolicyEvaluationContext context;
public PolicyExpressionEvaluator(PolicyEvaluationContext context)
{
this.context = context ?? throw new ArgumentNullException(nameof(context));
}
public EvaluationValue Evaluate(PolicyExpression expression, EvaluationScope? scope = null)
{
scope ??= EvaluationScope.Root(context);
return expression switch
{
PolicyLiteralExpression literal => new EvaluationValue(literal.Value),
PolicyListExpression list => new EvaluationValue(list.Items.Select(item => Evaluate(item, scope).Raw).ToImmutableArray()),
PolicyIdentifierExpression identifier => ResolveIdentifier(identifier.Name, scope),
PolicyMemberAccessExpression member => EvaluateMember(member, scope),
PolicyInvocationExpression invocation => EvaluateInvocation(invocation, scope),
PolicyIndexerExpression indexer => EvaluateIndexer(indexer, scope),
PolicyUnaryExpression unary => EvaluateUnary(unary, scope),
PolicyBinaryExpression binary => EvaluateBinary(binary, scope),
_ => EvaluationValue.Null,
};
}
public bool EvaluateBoolean(PolicyExpression expression, EvaluationScope? scope = null) =>
Evaluate(expression, scope).AsBoolean();
private EvaluationValue ResolveIdentifier(string name, EvaluationScope scope)
{
if (scope.TryGetLocal(name, out var local))
{
return new EvaluationValue(local);
}
return name switch
{
"severity" => new EvaluationValue(new SeverityScope(context.Severity)),
"env" => new EvaluationValue(new EnvironmentScope(context.Environment)),
"vex" => new EvaluationValue(new VexScope(this, context.Vex)),
"advisory" => new EvaluationValue(new AdvisoryScope(context.Advisory)),
"sbom" => new EvaluationValue(new SbomScope(context.Sbom)),
"true" => EvaluationValue.True,
"false" => EvaluationValue.False,
_ => EvaluationValue.Null,
};
}
private EvaluationValue EvaluateMember(PolicyMemberAccessExpression member, EvaluationScope scope)
{
var target = Evaluate(member.Target, scope);
var raw = target.Raw;
if (raw is SeverityScope severity)
{
return severity.Get(member.Member);
}
if (raw is EnvironmentScope env)
{
return env.Get(member.Member);
}
if (raw is VexScope vex)
{
return vex.Get(member.Member);
}
if (raw is AdvisoryScope advisory)
{
return advisory.Get(member.Member);
}
if (raw is SbomScope sbom)
{
return sbom.Get(member.Member);
}
if (raw is ImmutableDictionary<string, object?> dict && dict.TryGetValue(member.Member, out var value))
{
return new EvaluationValue(value);
}
if (raw is PolicyEvaluationVexStatement stmt)
{
return member.Member switch
{
"status" => new EvaluationValue(stmt.Status),
"justification" => new EvaluationValue(stmt.Justification),
"statementId" => new EvaluationValue(stmt.StatementId),
_ => EvaluationValue.Null,
};
}
return EvaluationValue.Null;
}
private EvaluationValue EvaluateInvocation(PolicyInvocationExpression invocation, EvaluationScope scope)
{
if (invocation.Target is PolicyIdentifierExpression identifier)
{
switch (identifier.Name)
{
case "severity_band":
var arg = invocation.Arguments.Length > 0 ? Evaluate(invocation.Arguments[0], scope).AsString() : null;
return new EvaluationValue(arg ?? string.Empty);
}
}
if (invocation.Target is PolicyMemberAccessExpression member && member.Target is PolicyIdentifierExpression root)
{
if (root.Name == "vex")
{
var vex = Evaluate(member.Target, scope);
if (vex.Raw is VexScope vexScope)
{
return member.Member switch
{
"any" => new EvaluationValue(vexScope.Any(invocation.Arguments, scope)),
"latest" => new EvaluationValue(vexScope.Latest()),
_ => EvaluationValue.Null,
};
}
}
if (root.Name == "sbom")
{
var sbom = Evaluate(member.Target, scope);
if (sbom.Raw is SbomScope sbomScope)
{
return member.Member switch
{
"has_tag" => sbomScope.HasTag(invocation.Arguments, scope, this),
_ => EvaluationValue.Null,
};
}
}
if (root.Name == "advisory")
{
var advisory = Evaluate(member.Target, scope);
if (advisory.Raw is AdvisoryScope advisoryScope)
{
return advisoryScope.Invoke(member.Member, invocation.Arguments, scope, this);
}
}
}
return EvaluationValue.Null;
}
private EvaluationValue EvaluateIndexer(PolicyIndexerExpression indexer, EvaluationScope scope)
{
var target = Evaluate(indexer.Target, scope).Raw;
var index = Evaluate(indexer.Index, scope).Raw;
if (target is ImmutableArray<object?> array && index is int i && i >= 0 && i < array.Length)
{
return new EvaluationValue(array[i]);
}
return EvaluationValue.Null;
}
private EvaluationValue EvaluateUnary(PolicyUnaryExpression unary, EvaluationScope scope)
{
var operand = Evaluate(unary.Operand, scope);
return unary.Operator switch
{
PolicyUnaryOperator.Not => new EvaluationValue(!operand.AsBoolean()),
_ => EvaluationValue.Null,
};
}
private EvaluationValue EvaluateBinary(PolicyBinaryExpression binary, EvaluationScope scope)
{
return binary.Operator switch
{
PolicyBinaryOperator.And => new EvaluationValue(EvaluateBoolean(binary.Left, scope) && EvaluateBoolean(binary.Right, scope)),
PolicyBinaryOperator.Or => new EvaluationValue(EvaluateBoolean(binary.Left, scope) || EvaluateBoolean(binary.Right, scope)),
PolicyBinaryOperator.Equal => Compare(binary.Left, binary.Right, scope, static (a, b) => Equals(a, b)),
PolicyBinaryOperator.NotEqual => Compare(binary.Left, binary.Right, scope, static (a, b) => !Equals(a, b)),
PolicyBinaryOperator.In => Contains(binary.Left, binary.Right, scope),
PolicyBinaryOperator.NotIn => new EvaluationValue(!Contains(binary.Left, binary.Right, scope).AsBoolean()),
PolicyBinaryOperator.LessThan => CompareNumeric(binary.Left, binary.Right, scope, static (a, b) => a < b),
PolicyBinaryOperator.LessThanOrEqual => CompareNumeric(binary.Left, binary.Right, scope, static (a, b) => a <= b),
PolicyBinaryOperator.GreaterThan => CompareNumeric(binary.Left, binary.Right, scope, static (a, b) => a > b),
PolicyBinaryOperator.GreaterThanOrEqual => CompareNumeric(binary.Left, binary.Right, scope, static (a, b) => a >= b),
_ => EvaluationValue.Null,
};
}
private EvaluationValue Compare(PolicyExpression left, PolicyExpression right, EvaluationScope scope, Func<object?, object?, bool> comparer)
{
var leftValue = Evaluate(left, scope).Raw;
var rightValue = Evaluate(right, scope).Raw;
return new EvaluationValue(comparer(leftValue, rightValue));
}
private EvaluationValue CompareNumeric(PolicyExpression left, PolicyExpression right, EvaluationScope scope, Func<decimal, decimal, bool> comparer)
{
var leftValue = Evaluate(left, scope);
var rightValue = Evaluate(right, scope);
if (!TryGetComparableNumber(leftValue, out var leftNumber)
|| !TryGetComparableNumber(rightValue, out var rightNumber))
{
return EvaluationValue.False;
}
return new EvaluationValue(comparer(leftNumber, rightNumber));
}
private static bool TryGetComparableNumber(EvaluationValue value, out decimal number)
{
var numeric = value.AsDecimal();
if (numeric.HasValue)
{
number = numeric.Value;
return true;
}
if (value.Raw is string text && SeverityOrder.TryGetValue(text.Trim(), out var mapped))
{
number = mapped;
return true;
}
number = 0m;
return false;
}
private EvaluationValue Contains(PolicyExpression needleExpr, PolicyExpression haystackExpr, EvaluationScope scope)
{
var needle = Evaluate(needleExpr, scope).Raw;
var haystack = Evaluate(haystackExpr, scope).Raw;
if (haystack is ImmutableArray<object?> array)
{
return new EvaluationValue(array.Any(item => Equals(item, needle)));
}
if (haystack is string str && needle is string needleString)
{
return new EvaluationValue(str.Contains(needleString, StringComparison.OrdinalIgnoreCase));
}
return new EvaluationValue(false);
}
internal readonly struct EvaluationValue
{
public static readonly EvaluationValue Null = new(null);
public static readonly EvaluationValue True = new(true);
public static readonly EvaluationValue False = new(false);
public EvaluationValue(object? raw)
{
Raw = raw;
}
public object? Raw { get; }
public bool AsBoolean()
{
return Raw switch
{
bool b => b,
string s => !string.IsNullOrWhiteSpace(s),
ImmutableArray<object?> array => !array.IsDefaultOrEmpty,
null => false,
_ => true,
};
}
public string? AsString()
{
return Raw switch
{
null => null,
string s => s,
decimal dec => dec.ToString("G", CultureInfo.InvariantCulture),
double d => d.ToString("G", CultureInfo.InvariantCulture),
int i => i.ToString(CultureInfo.InvariantCulture),
_ => Raw.ToString(),
};
}
public decimal? AsDecimal()
{
return Raw switch
{
decimal dec => dec,
double dbl => (decimal)dbl,
float fl => (decimal)fl,
int i => i,
long l => l,
string s when decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var value) => value,
_ => null,
};
}
}
internal sealed class EvaluationScope
{
private readonly IReadOnlyDictionary<string, object?> locals;
private EvaluationScope(IReadOnlyDictionary<string, object?> locals, PolicyEvaluationContext globals)
{
this.locals = locals;
Globals = globals;
}
public static EvaluationScope Root(PolicyEvaluationContext globals) =>
new EvaluationScope(new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase), globals);
public static EvaluationScope FromLocals(PolicyEvaluationContext globals, IReadOnlyDictionary<string, object?> locals) =>
new EvaluationScope(locals, globals);
public bool TryGetLocal(string name, out object? value)
{
if (locals.TryGetValue(name, out value))
{
return true;
}
value = null;
return false;
}
public PolicyEvaluationContext Globals { get; }
}
private sealed class SeverityScope
{
private readonly PolicyEvaluationSeverity severity;
public SeverityScope(PolicyEvaluationSeverity severity)
{
this.severity = severity;
}
public EvaluationValue Get(string member) => member switch
{
"normalized" => new EvaluationValue(severity.Normalized),
"score" => new EvaluationValue(severity.Score),
_ => EvaluationValue.Null,
};
}
private sealed class EnvironmentScope
{
private readonly PolicyEvaluationEnvironment environment;
public EnvironmentScope(PolicyEvaluationEnvironment environment)
{
this.environment = environment;
}
public EvaluationValue Get(string member)
{
var value = environment.Get(member)
?? environment.Get(member.ToLowerInvariant());
return new EvaluationValue(value);
}
}
private sealed class AdvisoryScope
{
private readonly PolicyEvaluationAdvisory advisory;
public AdvisoryScope(PolicyEvaluationAdvisory advisory)
{
this.advisory = advisory;
}
public EvaluationValue Get(string member) => member switch
{
"source" => new EvaluationValue(advisory.Source),
_ => advisory.Metadata.TryGetValue(member, out var value) ? new EvaluationValue(value) : EvaluationValue.Null,
};
public EvaluationValue Invoke(string member, ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
{
if (member.Equals("has_metadata", StringComparison.OrdinalIgnoreCase))
{
var key = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
if (string.IsNullOrEmpty(key))
{
return EvaluationValue.False;
}
return new EvaluationValue(advisory.Metadata.ContainsKey(key!));
}
return EvaluationValue.Null;
}
}
private sealed class SbomScope
{
private readonly PolicyEvaluationSbom sbom;
public SbomScope(PolicyEvaluationSbom sbom)
{
this.sbom = sbom;
}
public EvaluationValue Get(string member)
{
if (member.Equals("tags", StringComparison.OrdinalIgnoreCase))
{
return new EvaluationValue(sbom.Tags.ToImmutableArray<object?>());
}
return EvaluationValue.Null;
}
public EvaluationValue HasTag(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
{
var tag = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
if (string.IsNullOrWhiteSpace(tag))
{
return EvaluationValue.False;
}
return new EvaluationValue(sbom.HasTag(tag!));
}
}
private sealed class VexScope
{
private readonly PolicyExpressionEvaluator evaluator;
private readonly PolicyEvaluationVexEvidence vex;
public VexScope(PolicyExpressionEvaluator evaluator, PolicyEvaluationVexEvidence vex)
{
this.evaluator = evaluator;
this.vex = vex;
}
public EvaluationValue Get(string member) => member switch
{
"status" => new EvaluationValue(vex.Statements.IsDefaultOrEmpty ? null : vex.Statements[0].Status),
"justification" => new EvaluationValue(vex.Statements.IsDefaultOrEmpty ? null : vex.Statements[0].Justification),
_ => EvaluationValue.Null,
};
public bool Any(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope)
{
if (arguments.Length == 0 || vex.Statements.IsDefaultOrEmpty)
{
return false;
}
var predicate = arguments[0];
foreach (var statement in vex.Statements)
{
var locals = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["status"] = statement.Status,
["justification"] = statement.Justification,
["statement"] = statement,
["statementId"] = statement.StatementId,
};
var nestedScope = EvaluationScope.FromLocals(scope.Globals, locals);
if (evaluator.EvaluateBoolean(predicate, nestedScope))
{
return true;
}
}
return false;
}
public PolicyEvaluationVexStatement? Latest()
{
if (vex.Statements.IsDefaultOrEmpty)
{
return null;
}
return vex.Statements[^1];
}
}
}

View File

@@ -0,0 +1,12 @@
using System.Threading;
namespace StellaOps.Policy.Engine.Hosting;
internal sealed class PolicyEngineStartupDiagnostics
{
private int isReady;
public bool IsReady => Volatile.Read(ref isReady) == 1;
public void MarkReady() => Volatile.Write(ref isReady, 1);
}

View File

@@ -0,0 +1,168 @@
using System.Collections.ObjectModel;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Policy.Engine.Options;
/// <summary>
/// Root configuration for the Policy Engine host.
/// </summary>
public sealed class PolicyEngineOptions
{
public const string SectionName = "PolicyEngine";
public PolicyEngineAuthorityOptions Authority { get; } = new();
public PolicyEngineStorageOptions Storage { get; } = new();
public PolicyEngineWorkerOptions Workers { get; } = new();
public PolicyEngineResourceServerOptions ResourceServer { get; } = new();
public void Validate()
{
Authority.Validate();
Storage.Validate();
Workers.Validate();
ResourceServer.Validate();
}
}
public sealed class PolicyEngineAuthorityOptions
{
public bool Enabled { get; set; } = true;
public string Issuer { get; set; } = "https://authority.stella-ops.local";
public string ClientId { get; set; } = "policy-engine";
public string? ClientSecret { get; set; }
public IList<string> Scopes { get; } = new List<string>
{
StellaOpsScopes.PolicyRun,
StellaOpsScopes.FindingsRead,
StellaOpsScopes.EffectiveWrite
};
public int BackchannelTimeoutSeconds { get; set; } = 30;
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(Issuer))
{
throw new InvalidOperationException("Policy Engine authority configuration requires an issuer.");
}
if (!Uri.TryCreate(Issuer, UriKind.Absolute, out var issuerUri) || !issuerUri.IsAbsoluteUri)
{
throw new InvalidOperationException("Policy Engine authority issuer must be an absolute URI.");
}
if (issuerUri.Scheme != Uri.UriSchemeHttps && !issuerUri.IsLoopback)
{
throw new InvalidOperationException("Policy Engine authority issuer must use HTTPS unless targeting loopback.");
}
if (string.IsNullOrWhiteSpace(ClientId))
{
throw new InvalidOperationException("Policy Engine authority configuration requires a clientId.");
}
if (Scopes.Count == 0)
{
throw new InvalidOperationException("Policy Engine authority configuration requires at least one scope.");
}
if (BackchannelTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Policy Engine authority backchannel timeout must be greater than zero.");
}
}
}
public sealed class PolicyEngineStorageOptions
{
public string ConnectionString { get; set; } = "mongodb://localhost:27017/policy-engine";
public string DatabaseName { get; set; } = "policy_engine";
public int CommandTimeoutSeconds { get; set; } = 30;
public void Validate()
{
if (string.IsNullOrWhiteSpace(ConnectionString))
{
throw new InvalidOperationException("Policy Engine storage configuration requires a MongoDB connection string.");
}
if (string.IsNullOrWhiteSpace(DatabaseName))
{
throw new InvalidOperationException("Policy Engine storage configuration requires a database name.");
}
if (CommandTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Policy Engine storage command timeout must be greater than zero.");
}
}
public TimeSpan CommandTimeout => TimeSpan.FromSeconds(CommandTimeoutSeconds);
}
public sealed class PolicyEngineWorkerOptions
{
public int SchedulerIntervalSeconds { get; set; } = 15;
public int MaxConcurrentEvaluations { get; set; } = 4;
public void Validate()
{
if (SchedulerIntervalSeconds <= 0)
{
throw new InvalidOperationException("Policy Engine worker interval must be greater than zero.");
}
if (MaxConcurrentEvaluations <= 0)
{
throw new InvalidOperationException("Policy Engine worker concurrency must be greater than zero.");
}
}
}
public sealed class PolicyEngineResourceServerOptions
{
public string Authority { get; set; } = "https://authority.stella-ops.local";
public IList<string> Audiences { get; } = new List<string> { "api://policy-engine" };
public IList<string> RequiredScopes { get; } = new List<string> { StellaOpsScopes.PolicyRun };
public IList<string> RequiredTenants { get; } = new List<string>();
public IList<string> BypassNetworks { get; } = new List<string> { "127.0.0.1/32", "::1/128" };
public bool RequireHttpsMetadata { get; set; } = true;
public void Validate()
{
if (string.IsNullOrWhiteSpace(Authority))
{
throw new InvalidOperationException("Resource server configuration requires an Authority URL.");
}
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var uri))
{
throw new InvalidOperationException("Resource server Authority URL must be absolute.");
}
if (RequireHttpsMetadata && !uri.IsLoopback && !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Resource server Authority URL must use HTTPS when HTTPS metadata is required.");
}
}
}

View File

@@ -0,0 +1,139 @@
using System.IO;
using Microsoft.Extensions.Options;
using NetEscapades.Configuration.Yaml;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Policy.Engine.Hosting;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Endpoints;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Workers;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "STELLAOPS_POLICY_ENGINE_";
options.ConfigureBuilder = configurationBuilder =>
{
var contentRoot = builder.Environment.ContentRootPath;
foreach (var relative in new[]
{
"../etc/policy-engine.yaml",
"../etc/policy-engine.local.yaml",
"policy-engine.yaml",
"policy-engine.local.yaml"
})
{
var path = Path.Combine(contentRoot, relative);
configurationBuilder.AddYamlFile(path, optional: true);
}
};
});
var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyEngineOptions>(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "STELLAOPS_POLICY_ENGINE_";
options.BindingSection = PolicyEngineOptions.SectionName;
options.ConfigureBuilder = configurationBuilder =>
{
foreach (var relative in new[]
{
"../etc/policy-engine.yaml",
"../etc/policy-engine.local.yaml",
"policy-engine.yaml",
"policy-engine.local.yaml"
})
{
var path = Path.Combine(builder.Environment.ContentRootPath, relative);
configurationBuilder.AddYamlFile(path, optional: true);
}
};
options.PostBind = static (value, _) => value.Validate();
});
builder.Configuration.AddConfiguration(bootstrap.Configuration);
builder.Services.AddOptions<PolicyEngineOptions>()
.Bind(builder.Configuration.GetSection(PolicyEngineOptions.SectionName))
.Validate(options =>
{
try
{
options.Validate();
return true;
}
catch (Exception ex)
{
throw new OptionsValidationException(
PolicyEngineOptions.SectionName,
typeof(PolicyEngineOptions),
new[] { ex.Message });
}
})
.ValidateOnStart();
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyEngineOptions>>().Value);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
builder.Services.AddSingleton<PolicyCompiler>();
builder.Services.AddSingleton<PolicyCompilationService>();
builder.Services.AddSingleton<PolicyEvaluationService>();
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
builder.Services.AddStellaOpsScopeHandler();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: $"{PolicyEngineOptions.SectionName}:ResourceServer");
if (bootstrap.Options.Authority.Enabled)
{
builder.Services.AddStellaOpsAuthClient(clientOptions =>
{
clientOptions.Authority = bootstrap.Options.Authority.Issuer;
clientOptions.ClientId = bootstrap.Options.Authority.ClientId;
clientOptions.ClientSecret = bootstrap.Options.Authority.ClientSecret;
clientOptions.HttpTimeout = TimeSpan.FromSeconds(bootstrap.Options.Authority.BackchannelTimeoutSeconds);
clientOptions.DefaultScopes.Clear();
foreach (var scope in bootstrap.Options.Authority.Scopes)
{
clientOptions.DefaultScopes.Add(scope);
}
});
}
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/healthz");
app.MapGet("/readyz", (PolicyEngineStartupDiagnostics diagnostics) =>
diagnostics.IsReady
? Results.Ok(new { status = "ready" })
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable))
.WithName("Readiness");
app.MapGet("/", () => Results.Redirect("/healthz"));
app.MapPolicyCompilation();
app.MapPolicyPacks();
app.Run();

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Policy.Engine.Tests")]

View File

@@ -0,0 +1,14 @@
# Policy Engine Host Template
This service hosts the Policy Engine APIs and background workers introduced in **Policy Engine v2**. The project currently ships a minimal bootstrap that validates configuration, registers Authority clients, and exposes readiness/health endpoints. Future tasks will extend it with compilation, evaluation, and persistence features.
## Compliance Checklist
- [x] Configuration loads from `policy-engine.yaml`/environment variables and validates on startup.
- [x] Authority client scaffolding enforces `policy:*` + `effective:write` scopes and respects back-channel timeouts.
- [x] Resource server authentication requires Policy Engine scopes with tenant-aware policies.
- [x] Health and readiness endpoints exist for platform probes.
- [ ] Deterministic policy evaluation pipeline implemented (POLICY-ENGINE-20-002).
- [ ] Mongo materialisation writers implemented (POLICY-ENGINE-20-004).
- [ ] Observability (metrics/traces/logs) completed (POLICY-ENGINE-20-007).
- [ ] Comprehensive test suites and perf baselines established (POLICY-ENGINE-20-008).

View File

@@ -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
}

View File

@@ -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);
}
}

View File

@@ -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"
};
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,20 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,182 @@
# Policy Engine Service Task Board — Epic 2
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-ENGINE-20-000 | DONE (2025-10-26) | Policy Guild, BE-Base Platform Guild | POLICY-AOC-19-001 | Spin up new `StellaOps.Policy.Engine` service project (minimal API host + worker), wire DI composition root, configuration binding, and Authority client scaffolding. | New project builds/tests; registered in solution; bootstrap validates configuration; host template committed with compliance checklist. |
> 2025-10-26: Added policy-engine host bootstrap (config, auth client, resource server auth, readiness probe) + sample YAML and compliance readme.
| POLICY-ENGINE-20-001 | DONE (2025-10-26) | Policy Guild, Language Infrastructure Guild | POLICY-ENGINE-20-000 | Implement `stella-dsl@1` parser + IR compiler with grammar validation, syntax diagnostics, and checksum outputs for caching. | DSL parser handles full grammar + error reporting; IR checksum stored with policy version; unit tests cover success/error paths. |
| POLICY-ENGINE-20-002 | BLOCKED (2025-10-26) | Policy Guild | POLICY-ENGINE-20-001 | Build deterministic evaluator honoring lexical/priority order, first-match semantics, and safe value types (no wall-clock/network access). | Evaluator executes policies deterministically in unit/property tests; guard rejects forbidden intrinsics; perf baseline recorded. |
> 2025-10-26: Blocked while bootstrapping DSL parser/evaluator; remaining grammar coverage (profile keywords, condition parsing) and rule evaluation semantics still pending to satisfy acceptance tests.
| POLICY-ENGINE-20-003 | TODO | Policy Guild, Concelier Core Guild, Excititor Core Guild | POLICY-ENGINE-20-001, CONCELIER-POLICY-20-002, EXCITITOR-POLICY-20-002 | Implement selection joiners resolving SBOM↔advisory↔VEX tuples using linksets and PURL equivalence tables, with deterministic batching. | Joiners fetch correct candidate sets in integration tests; batching meets memory targets; explain traces list input provenance. |
> 2025-10-26: Scheduler DTO contracts for runs/diffs/explains available (`src/Scheduler/__Libraries/StellaOps.Scheduler.Models/docs/SCHED-MODELS-20-001-POLICY-RUNS.md`); consume `PolicyRunRequest/Status/DiffSummary` from samples under `samples/api/scheduler/`.
| POLICY-ENGINE-20-004 | TODO | Policy Guild, Platform Storage Guild | POLICY-ENGINE-20-003, CONCELIER-POLICY-20-003, EXCITITOR-POLICY-20-003 | Ship materialization writer that upserts into `effective_finding_{policyId}` with append-only history, tenant scoping, and trace references. | Writes restricted to Policy Engine identity; idempotent upserts proven via tests; collections indexed per design and docs updated. |
| POLICY-ENGINE-20-005 | TODO | Policy Guild, Security Engineering | POLICY-ENGINE-20-002 | Enforce determinism guard banning wall-clock, RNG, and network usage during evaluation via static analysis + runtime sandbox. | Guard blocks forbidden APIs in unit/integration tests; violations emit `ERR_POL_004`; CI analyzer wired. |
| POLICY-ENGINE-20-006 | TODO | Policy Guild, Scheduler Worker Guild | POLICY-ENGINE-20-003, POLICY-ENGINE-20-004, SCHED-WORKER-20-301 | Implement incremental orchestrator reacting to advisory/vex/SBOM change streams and scheduling partial policy re-evaluations. | Change stream listeners enqueue affected tuples with dedupe; orchestrator meets 5 min SLA in perf tests; metrics exposed (`policy_run_seconds`). |
> 2025-10-29: Scheduler worker delta targeting (SCHED-WORKER-20-302) is live; change-stream orchestrator should supply metadata (`delta.*`) expected by the worker before enabling incremental benches/benchmarks.
| POLICY-ENGINE-20-007 | TODO | Policy Guild, Observability Guild | POLICY-ENGINE-20-002 | Emit structured traces/logs of rule hits with sampling controls, metrics (`rules_fired_total`, `vex_overrides_total`), and expose explain trace exports. | Trace spans present in integration tests; metrics registered with counters/histograms; sampled rule hit logs validated. |
| POLICY-ENGINE-20-008 | TODO | Policy Guild, QA Guild | POLICY-ENGINE-20-002, POLICY-ENGINE-20-003, POLICY-ENGINE-20-004, POLICY-ENGINE-20-005, POLICY-ENGINE-20-006, POLICY-ENGINE-20-007 | Add unit/property/golden/perf suites covering policy compilation, evaluation correctness, determinism, and SLA targets. | Golden fixtures pass deterministically across two seeded runs; property tests run in CI; perf regression budget documented. |
| POLICY-ENGINE-20-009 | TODO | Policy Guild, Storage Guild | POLICY-ENGINE-20-000, POLICY-ENGINE-20-004 | Define Mongo schemas/indexes for `policies`, `policy_runs`, and `effective_finding_*`; implement migrations and tenant enforcement. | Collections + indexes created via bootstrapper; migrations documented; tests cover tenant scoping + write restrictions. |
## Policy Studio RBAC Alignment (Sprint 27)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-ENGINE-27-001 | DONE (2025-10-31) | Policy Guild, Security Guild | AUTH-POLICY-27-001, POLICY-ENGINE-20-004 | Replace legacy `policy:write/submit` scope usage across Policy Engine API/worker/scheduler clients with the new Policy Studio scope family (`policy:author/review/approve/operate/audit/simulate`), update bootstrap configuration and tests, and ensure RBAC denials surface deterministic errors. | All configs/tests reference new scope set, integration tests cover missing-scope failures, CLI/docs samples updated, and CI guard prevents reintroduction of legacy scope names. |
> 2025-10-31: Policy Gateway now enforces `policy:author/review/operate` scopes, configuration defaults and Offline Kit samples updated, Authority clients seeded with new bundles, and scope verification script adjusted for the refreshed set.
## Gateway Implementation (Sprint 18.5)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-GATEWAY-18-001 | DONE (2025-10-27) | Policy Gateway Strike Team | POLICY-ENGINE-20-000 | Bootstrap Policy Gateway host (`StellaOps.Policy.Gateway`) with configuration bootstrap, Authority resource-server auth, structured logging, health endpoints, and solution registration. | Gateway project builds/tests, configuration validation wired, `/healthz` + `/readyz` exposed, logging uses standard format. |
> 2025-10-27: Added the `StellaOps.Policy.Gateway` project with configuration bootstrapper, JSON logging, Authority resource server auth, and health/readiness endpoints plus sample config and solution wiring.
| POLICY-GATEWAY-18-002 | DONE (2025-10-27) | Policy Gateway Strike Team | POLICY-GATEWAY-18-001 | Implement proxy routes for policy packs/revisions (`GET/POST /api/policy/packs`, `/revisions`) with scope enforcement (`policy:read`, `policy:edit`) and deterministic DTOs. | Endpoints proxy to Policy Engine, unit tests cover happy/error paths, unauthorized requests rejected correctly. |
> 2025-10-27: Implemented `/api/policy/packs` gateway routes with per-scope authorisation, forwarded bearer/DPoP/tenant headers, typed Policy Engine client, and deterministic DTO/ProblemDetails mapping.
| POLICY-GATEWAY-18-003 | DONE (2025-10-27) | Policy Gateway Strike Team | POLICY-GATEWAY-18-002 | Implement activation proxy (`POST /api/policy/packs/{packId}/revisions/{version}:activate`) supporting single/two-person flows, returning 202 when awaiting second approval, and emitting structured logs/metrics. | Activation responses match Policy Engine contract, logs include tenant/actor/pack info, metrics published for outcomes. |
> 2025-10-27: Gateway proxy annotates activation outcomes (`activated`, `pending_second_approval`, etc.), emits `policy_gateway_activation_*` metrics, and logs PackId/Version/Tenant for auditability.
| POLICY-GATEWAY-18-004 | DONE (2025-10-27) | Policy Gateway Strike Team | POLICY-GATEWAY-18-001 | Add typed HttpClient for Policy Engine with DPoP client credentials, retry/backoff, and consistent error mapping to ProblemDetails. | HttpClient registered with resilient pipeline, integration tests verify error translation and token usage. |
> 2025-10-27: Added client-credential fallback with ES256 DPoP proofs, Polly retry policy, and uniform ProblemDetails mapping for upstream failures.
| POLICY-GATEWAY-18-005 | DONE (2025-10-27) | Policy Gateway Strike Team | POLICY-GATEWAY-18-002, POLICY-GATEWAY-18-003 | Update docs/offline kit configs with new gateway service, sample curl commands, and CLI/UI integration guidance. | Docs merged, Offline Kit includes gateway config, verification script updated, release notes prepared. |
> 2025-10-27: Published `/docs/policy/gateway.md`, Offline Kit instructions for bundling configs/keys, and curl workflows for Console/CLI verification.
## StellaOps Console (Sprint 23)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-CONSOLE-23-001 | TODO | Policy Guild, BE-Base Platform Guild | POLICY-ENGINE-20-003, POLICY-ENGINE-20-004, POLICY-ENGINE-20-007 | Optimize findings/explain APIs for Console: cursor-based pagination at scale, global filter parameters (severity bands, policy version, time window), rule trace summarization, and aggregation hints for dashboard cards. Ensure deterministic ordering and expose provenance refs. | APIs return deterministic cursors, aggregation hints validated against golden fixtures, latency SLO ≤ 250ms P95 on seeded data, documentation updated. |
| POLICY-CONSOLE-23-002 | TODO | Policy Guild, Product Ops | POLICY-ENGINE-20-006, POLICY-ENGINE-20-007, POLICY-ENGINE-20-008 | Produce simulation diff metadata (before/after counts, severity deltas, rule impact summaries) and approval state endpoints consumed by Console policy workspace; expose RBAC-aware status transitions. | Simulation diff payload documented, approval endpoints enforce scopes, integration tests cover workflow paths, metrics record diff generation latency. |
| EXPORT-CONSOLE-23-001 | TODO | Policy Guild, Scheduler Guild, Observability Guild | POLICY-ENGINE-20-004, SCHED-WORKER-20-301, POLICY-CONSOLE-23-001 | Build evidence bundle/export generator producing signed manifests, CSV/JSON replay endpoints, and trace attachments; integrate with scheduler jobs and expose progress telemetry. | Evidence bundles reproducible with checksums, manifests signed (cosign), API streams zipped content, telemetry metrics/logs added, docs updated. |
## Policy Studio (Sprint 27)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-ENGINE-27-001 | TODO | Policy Guild | POLICY-ENGINE-20-001, REGISTRY-API-27-003 | Extend compile outputs to include rule coverage metadata, symbol table, inline documentation, and rule index for editor autocomplete; persist deterministic hashes. | Compile endpoint returns coverage + symbol table; responses validated with fixtures; hashing deterministic across runs; docs updated. |
| POLICY-ENGINE-27-002 | TODO | Policy Guild, Observability Guild | POLICY-ENGINE-20-002, POLICY-ENGINE-27-001 | Enhance simulate endpoints to emit rule firing counts, heatmap aggregates, sampled explain traces with deterministic ordering, and delta summaries for quick/batch sims. | Simulation outputs include ordered heatmap + sample explains; integration tests verify determinism; telemetry emits `policy_rule_fired_total`. |
| POLICY-ENGINE-27-003 | TODO | Policy Guild, Security Guild | POLICY-ENGINE-20-005 | Implement complexity/time limit enforcement with compiler scoring, configurable thresholds, and structured diagnostics (`ERR_POL_COMPLEXITY`). | Policies exceeding limits return actionable diagnostics; limits configurable per tenant; regression tests cover allow/block cases. |
| POLICY-ENGINE-27-004 | TODO | Policy Guild, QA Guild | POLICY-ENGINE-27-001..003 | Update golden/property tests to cover new coverage metrics, symbol tables, explain traces, and complexity limits; provide fixtures for Registry/Console integration. | Test suites extended; fixtures shared under `StellaOps.Policy.Engine.Tests/Fixtures/policy-studio`; CI ensures determinism across runs. |
## Epic 3: Graph Explorer v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-ENGINE-30-001 | TODO | Policy Guild, Cartographer Guild | POLICY-ENGINE-20-004, CARTO-GRAPH-21-005 | Define overlay contract for graph nodes/edges (status, severity, rationale refs, path relevance), expose projection API for Cartographer, and document schema versioning. | Overlay contract published (OpenAPI + schema); integration tests validate payloads against fixtures; versioning strategy documented. |
| POLICY-ENGINE-30-002 | TODO | Policy Guild, Cartographer Guild | POLICY-ENGINE-30-001, CARTO-GRAPH-21-006 | Implement simulation bridge returning on-the-fly overlays for Cartographer/Graph Explorer when invoking Policy Engine simulate; ensure no writes and deterministic outputs. | Simulation API returns overlays within SLA; end-to-end test from Graph Explorer consumes results; docs updated. |
| POLICY-ENGINE-30-003 | TODO | Policy Guild, Scheduler Guild, Cartographer Guild | POLICY-ENGINE-20-006, CARTO-GRAPH-21-007 | Emit change events (`policy.effective.updated`) with graph-friendly payloads so Cartographer overlay worker refreshes nodes/edges within 2 minutes. | Event published on run completion; Cartographer listener integration test passes; metrics capture lag. |
| POLICY-ENGINE-30-101 | TODO | Policy Guild | POLICY-ENGINE-29-001 | Surface trust weighting configuration (issuer base weights, signature modifiers, recency decay, scope adjustments) for VEX Lens via Policy Studio + API; ensure deterministic evaluation. | Trust weighting config exposed; Policy Studio UI updated; integration tests verify VEX Lens consumption. |
## Link-Not-Merge v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-ENGINE-40-001 | TODO | Policy Guild, Concelier Guild | CONCELIER-LNM-21-002 | Update severity/status evaluation pipelines to consume multiple source severities per linkset, supporting selection strategies (max, preferred source, policy-defined). | Policy evaluation handles multiple source inputs; tests cover selection strategies; documentation updated. |
| POLICY-ENGINE-40-002 | TODO | Policy Guild, Excititor Guild | EXCITITOR-LNM-21-002 | Accept VEX linkset conflicts and provide rationale references in effective findings; ensure explain traces cite observation IDs. | Effective findings include observation IDs + conflict reasons; explain endpoints updated; integration tests added. |
| POLICY-ENGINE-40-003 | TODO | Policy Guild, Web Scanner Guild | POLICY-ENGINE-40-001 | Provide API/SDK utilities for consumers (Web Scanner, Graph Explorer) to request policy decisions with source evidence summaries (top severity sources, conflict counts). | Utilities published; Web Scanner integration tests confirm new payload; docs updated. |
## Vulnerability Explorer (Sprint 29)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-ENGINE-29-001 | TODO | Policy Guild | POLICY-ENGINE-27-001 | Implement batch evaluation endpoint (`POST /policy/eval/batch`) returning determinations + rationale chain for sets of `(artifact,purl,version,advisory)` tuples; support pagination and cost budgets. | Endpoint documented; latency within SLA; integration tests cover large batches; telemetry recorded. |
| POLICY-ENGINE-29-002 | TODO | Policy Guild, Findings Ledger Guild | POLICY-ENGINE-29-001, LEDGER-29-003 | Provide streaming simulation API comparing two policy versions, returning per-finding deltas without writes; align determinism with Vuln Explorer simulation. | Simulation output deterministic; diff schema shared; tests cover suppression/severity changes. |
| POLICY-ENGINE-29-003 | TODO | Policy Guild, SBOM Service Guild | POLICY-ENGINE-29-001, SBOM-VULN-29-001 | Surface path/scope awareness in determinations (signal optional/dev/test downgrade, runtime boost) for Vuln Explorer display. | Determinations include path annotations; policy docs updated; tests cover path-specific cases. |
| POLICY-ENGINE-29-004 | TODO | Policy Guild, Observability Guild | POLICY-ENGINE-29-001 | Add metrics/logs for batch evaluation (latency, queue depth) and simulation diff counts; update dashboards. | Metrics exposed; dashboards updated; alert thresholds defined. |
## Advisory AI (Sprint 31)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-ENGINE-31-001 | TODO | Policy Guild | VEXLENS-30-008, AIAI-31-004 | Expose policy knobs for Advisory AI (trust presets, temperature, token limits, plan ranking weights, TTLs) via Policy Studio and config APIs. | Knobs available; Policy Studio integration documented; tests cover overrides. |
| POLICY-ENGINE-31-002 | TODO | Policy Guild | POLICY-ENGINE-31-001 | Provide batch endpoint delivering policy context (thresholds, obligations) consumed by Advisory AI remediation planner. | Endpoint documented; integration tests confirm data; latency within SLA. |
## Policy Engine + Editor v1 (Epic 5)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-ENGINE-50-001 | TODO | Policy Guild, Platform Security | POLICY-SPL-23-002 | Implement SPL compiler: validate YAML, canonicalize, produce signed bundle, store artifact in object storage, write `policy_revisions` with AOC metadata. | Compiler CLI/API available; bundles stored with hashes/AOC; unit/integration tests green. |
| POLICY-ENGINE-50-002 | TODO | Policy Guild, Runtime Guild | POLICY-ENGINE-50-001 | Build runtime evaluator executing compiled plans over advisory/vex linksets + SBOM asset metadata with deterministic caching (Redis) and fallback path. | Evaluator meets latency targets; cache hit/miss metrics emitted; deterministic tests pass across runs. |
| POLICY-ENGINE-50-003 | TODO | Policy Guild, Observability Guild | POLICY-ENGINE-50-002 | Implement evaluation/compilation metrics, tracing, and structured logs (`policy_eval_seconds`, `policy_compiles_total`, explanation sampling). | Metrics available in Prometheus; traces wired; log schema documented. |
| POLICY-ENGINE-50-004 | TODO | Policy Guild, Platform Events Guild | POLICY-ENGINE-50-002, CONCELIER-LNM-21-005, EXCITITOR-LNM-21-005, SBOM-SERVICE-21-002 | Build event pipeline: subscribe to linkset/SBOM updates, schedule re-eval jobs, emit `policy.effective.updated` events with diff metadata. | Events consumed/produced reliably; idempotent keys; integration tests with mock inputs. |
| POLICY-ENGINE-50-005 | TODO | Policy Guild, Storage Guild | POLICY-ENGINE-50-001 | Design and implement `policy_packs`, `policy_revisions`, `policy_runs`, `policy_artifacts` collections with indexes, TTL, and tenant scoping. | Collections + indexes created via migrations; documentation of schema; tests cover CRUD. |
| POLICY-ENGINE-50-006 | TODO | Policy Guild, QA Guild | POLICY-ENGINE-50-002 | Implement explainer persistence + retrieval APIs linking decisions to explanation tree and AOC chain. | Explain data stored/retrievable via API; UI/CLI fixtures updated; determinism verified. |
| POLICY-ENGINE-50-007 | TODO | Policy Guild, Scheduler Worker Guild | POLICY-ENGINE-50-004, SCHED-WORKER-23-101 | Provide evaluation worker host/DI wiring and job orchestration hooks for batch re-evaluations after policy activation. | Worker host runs in CI; handles sharded workloads; telemetry integrated. |
## Graph & Vuln Explorer v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-ENGINE-60-001 | TODO | Policy Guild, SBOM Service Guild | POLICY-ENGINE-50-004, SBOM-GRAPH-24-002 | Maintain Redis effective decision maps per asset/snapshot for Graph overlays; implement versioning and eviction strategy. | Cache warmed with metrics; invalidation on policy/graph updates; tests ensure consistency. |
| POLICY-ENGINE-60-002 | TODO | Policy Guild, BE-Base Platform Guild | POLICY-ENGINE-60-001, WEB-GRAPH-24-002 | Expose simulation bridge for Graph What-if APIs, supporting hypothetical SBOM diffs and draft policies without persisting results. | Simulation API returns projections; integration tests verify idempotence; performance <3s for target assets. |
## Exceptions v1 (Epic 7)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-ENGINE-70-001 | DONE (2025-10-27) | Policy Guild, Governance Guild | POLICY-EXC-25-001 | Implement exception evaluation layer: specificity resolution, effect application (suppress/defer/downgrade/require control), and integration with explain traces. | Engine applies exceptions deterministically; unit/property tests cover precedence; explainer includes exception metadata. |
| POLICY-ENGINE-70-002 | TODO | Policy Guild, Storage Guild | POLICY-ENGINE-70-001 | Design and create Mongo collections (`exceptions`, `exception_reviews`, `exception_bindings`) with indexes and migrations; expose repository APIs. | Collections created; migrations documented; tests cover CRUD and binding lookups. |
| POLICY-ENGINE-70-003 | TODO | Policy Guild, Runtime Guild | POLICY-ENGINE-70-001 | Build Redis exception decision cache (`exceptions_effective_map`) with warm/invalidation logic reacting to `exception.*` events. | Cache layer operational; metrics track hit/miss; fallback path tested. |
| POLICY-ENGINE-70-004 | TODO | Policy Guild, Observability Guild | POLICY-ENGINE-70-001 | Extend metrics/tracing/logging for exception application (latency, counts, expiring events) and include AOC references in logs. | Metrics emitted (`policy_exception_applied_total` etc.); traces updated; log schema documented. |
| POLICY-ENGINE-70-005 | TODO | Policy Guild, Scheduler Worker Guild | POLICY-ENGINE-70-002 | Provide APIs/workers hook for exception activation/expiry (auto start/end) and event emission (`exception.activated/expired`). | Auto transitions tested; events published; integration with workers verified. |
## Reachability v1 (Epic 8)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-ENGINE-80-001 | TODO | Policy Guild, Signals Guild | SIGNALS-24-004 | Integrate reachability/exploitability inputs into evaluation pipeline (state/score/confidence) with caching and explain support. | Policy evaluation consumes signals data; explainer includes reachability evidence; tests cover scoring impact. |
| POLICY-ENGINE-80-002 | TODO | Policy Guild, Storage Guild | SIGNALS-24-004 | Create joining layer to read `reachability_facts` efficiently (indexes, projections) and populate Redis overlay caches. | Queries optimized with indexes; cache warmed; performance <8 ms p95; tests pass. |
| POLICY-ENGINE-80-003 | TODO | Policy Guild, Policy Editor Guild | POLICY-ENGINE-80-001 | Extend SPL predicates/actions to reference reachability state/score/confidence; update compiler validation. | SPL accepts new predicates; canonicalization updated; schema docs regenerated. |
| POLICY-ENGINE-80-004 | TODO | Policy Guild, Observability Guild | POLICY-ENGINE-80-001 | Emit metrics (`policy_reachability_applied_total`, `policy_reachability_cache_hit_ratio`) and traces for signals usage. | Metrics/traces available; dashboards updated; alert thresholds defined. |
## Orchestrator Dashboard (Epic 9)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-ENGINE-32-101 | TODO | Policy Guild | ORCH-SVC-32-001, ORCH-SVC-32-003 | Define orchestrator `policy_eval` job schema, idempotency keys, and enqueue hooks triggered by advisory/VEX/SBOM events. | Job schema documented; enqueue hooks tested; OpenAPI references updated; determinism tests cover idempotent keys. |
| POLICY-ENGINE-33-101 | TODO | Policy Guild | POLICY-ENGINE-32-101, ORCH-SVC-33-001, WORKER-GO-33-001, WORKER-PY-33-001 | Implement orchestrator-driven policy evaluation workers using SDK heartbeats, respecting throttles, and emitting SLO metrics. | Worker claims jobs in integration tests; metrics exported; pause/resume/backfill scenarios covered; docs updated. |
| POLICY-ENGINE-34-101 | TODO | Policy Guild | POLICY-ENGINE-33-101, ORCH-SVC-34-001, LEDGER-34-101 | Publish policy run ledger exports + SLO burn-rate metrics to orchestrator; ensure provenance chain links to Findings Ledger. | Ledger export endpoint live; burn metrics recorded; tests ensure tenant isolation; documentation references run-ledger doc. |
## Export Center (Epic 10)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-ENGINE-35-201 | TODO | Policy Guild | POLICY-ENGINE-20-004, LEDGER-EXPORT-35-001 | Expose deterministic policy snapshot API and evaluated findings stream keyed by policy version for exporter consumption. | Snapshot endpoint live; outputs deterministic; provenance metadata included; tests cover policy pinning. |
| POLICY-ENGINE-38-201 | TODO | Policy Guild | ORCH-SVC-38-101 | Emit enriched policy violation events (decision rationale ids, risk bands) via orchestrator event bus for Notifications Studio. | Events published with rationale IDs; schema documented; integration tests with notifier ensure fields present. |
## Authority-Backed Scopes & Tenancy (Epic 14)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-TEN-48-001 | TODO | Policy Guild | AUTH-TEN-47-001 | Add `tenant_id`/`project_id` columns, enable RLS, update evaluators to require tenant context, and emit rationale IDs including tenant metadata. | RLS enabled; tests prove isolation; rationale IDs stable; docs updated. |
## Observability & Forensics (Epic 15)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-OBS-50-001 | TODO | Policy Guild, Observability Guild | TELEMETRY-OBS-50-001, TELEMETRY-OBS-50-002 | Integrate telemetry core into policy API + worker hosts, ensuring spans/logs cover compile/evaluate flows with `tenant_id`, `policy_version`, `decision_effect`, and trace IDs. | Telemetry observed in integration tests; logging contract validated; CLI trace propagation confirmed. |
| POLICY-OBS-51-001 | TODO | Policy Guild, DevOps Guild | POLICY-OBS-50-001, TELEMETRY-OBS-51-001 | Emit golden-signal metrics (compile latency, evaluate latency, rule hits, override counts) and define SLOs (evaluation P95 <2s). Publish Grafana dashboards + burn-rate alert rules. | Metrics visible in dashboards; SLO alert tested; documentation updated. |
| POLICY-OBS-52-001 | TODO | Policy Guild | POLICY-OBS-50-001, TIMELINE-OBS-52-002 | Emit timeline events `policy.evaluate.started`, `policy.evaluate.completed`, `policy.decision.recorded` with trace IDs, input digests, and rule summary. Provide contract tests and retry semantics. | Timeline events pass fixture tests; duplicates prevented; docs reference schema. |
| POLICY-OBS-53-001 | TODO | Policy Guild, Evidence Locker Guild | POLICY-OBS-52-001, EVID-OBS-53-002 | Produce evaluation evidence bundles (inputs slice, rule trace, engine version, config snapshot) through evidence locker integration; ensure redaction + deterministic manifests. | Bundles generated/verified in integration tests; manifests deterministic; redaction guard tests pass. |
| POLICY-OBS-54-001 | TODO | Policy Guild, Provenance Guild | POLICY-OBS-53-001, PROV-OBS-53-002 | Generate DSSE attestations for evaluation outputs, expose `/evaluations/{id}/attestation`, and link attestation IDs in timeline + console. Provide verification harness. | Attestations validated; endpoint live; docs updated. |
| POLICY-OBS-55-001 | TODO | Policy Guild, DevOps Guild | POLICY-OBS-51-001, DEVOPS-OBS-55-001 | Implement incident mode sampling overrides (full rule trace capture, extended retention) with auto-activation on SLO breach and manual override API. Emit activation events to timeline + notifier. | Incident mode validated; retention resets post incident; activation logged. |
## Risk Profiles (Epic 18)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-RISK-66-003 | TODO | Policy Guild, Risk Profile Schema Guild | POLICY-RISK-66-001 | Integrate RiskProfile schema into Policy Engine configuration, ensuring validation and default profile deployment. | Policy Engine loads profiles with schema validation; unit tests cover invalid docs. |
| POLICY-RISK-67-001 | TODO | Policy Guild, Risk Engine Guild | POLICY-RISK-66-003, RISK-ENGINE-66-001 | Trigger scoring jobs on new/updated findings via Policy Engine orchestration hooks. | Scoring jobs enqueued deterministically; tests cover delta events. |
| POLICY-RISK-67-002 | TODO | Policy Guild | POLICY-RISK-66-003 | Implement profile lifecycle APIs (`/risk/profiles` create/publish/deprecate) and scope attachment logic. | APIs documented; authorization enforced; integration tests pass. |
| POLICY-RISK-68-001 | TODO | Policy Guild, Policy Studio Guild | POLICY-RISK-67-002 | Provide simulation API bridging Policy Studio with risk engine; returns distributions and top movers. | Simulation endpoint live with documented schema; golden tests verified. |
| POLICY-RISK-69-001 | TODO | Policy Guild, Notifications Guild | POLICY-RISK-67-002 | Emit events/notifications on profile publish, deprecate, and severity threshold changes. | Notifications templates live; staging event triggers announcement. |
| POLICY-RISK-70-001 | TODO | Policy Guild, Export Guild | POLICY-RISK-67-002, RISK-BUNDLE-69-001 | Support exporting/importing profiles with signatures for air-gapped bundles. | Export/import CLI works; signatures verified; docs updated. |
## Attestor Console (Epic 19)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-ATTEST-73-001 | TODO | Policy Guild, Attestor Service Guild | ATTESTOR-73-002 | Introduce VerificationPolicy object: schema, persistence, versioning, and lifecycle. | Policy CRUD operational; validation implemented; tests cover publish/deprecate. |
| POLICY-ATTEST-73-002 | TODO | Policy Guild | POLICY-ATTEST-73-001 | Provide Policy Studio editor with validation, dry-run simulation, and version diff. | UI supports editing/publishing policies; dry-run returns detailed feedback; docs updated. |
| POLICY-ATTEST-74-001 | TODO | Policy Guild, Attestor Service Guild | POLICY-ATTEST-73-001 | Integrate verification policies into attestor verification pipeline with caching and waiver support. | Verification uses policies; waivers logged; regression suite passes. |
| POLICY-ATTEST-74-002 | TODO | Policy Guild, Console Guild | POLICY-ATTEST-73-002 | Surface policy evaluations in Console verification reports with rule explanations. | Reports show rule hits/misses; tests confirm data flow. |
## Air-Gapped Mode (Epic 16)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-AIRGAP-56-001 | TODO | Policy Guild | AIRGAP-IMP-56-001, CONCELIER-OBS-52-001 | Support policy pack imports from Mirror Bundles, track `bundle_id` metadata, and ensure deterministic caching. | Policy packs import via API/CLI; bundle ID persisted; tests cover idempotent re-import and rollback. |
| POLICY-AIRGAP-56-002 | TODO | Policy Guild, Policy Studio Guild | POLICY-AIRGAP-56-001, MIRROR-CRT-56-001 | Export policy sub-bundles (`stella policy bundle export`) with DSSE signatures for outbound transfer. | Export command produces signed bundle; verification succeeds; docs updated. |
| POLICY-AIRGAP-57-001 | TODO | Policy Guild, AirGap Policy Guild | POLICY-AIRGAP-56-001, AIRGAP-POL-56-001 | Enforce sealed-mode guardrails in evaluation (no outbound fetch), surface `AIRGAP_EGRESS_BLOCKED` errors with remediation. | Evaluations fail with standard error when egress attempt occurs; unit tests cover sealed/unsealed. |
| POLICY-AIRGAP-57-002 | TODO | Policy Guild, AirGap Time Guild | POLICY-AIRGAP-56-001, AIRGAP-TIME-58-001 | Annotate rule explanations with staleness information and fallback data (cached EPSS, vendor risk). | Explain output shows fallback source + timestamp; UI consumes new fields; tests updated. |
| POLICY-AIRGAP-58-001 | TODO | Policy Guild, Notifications Guild | POLICY-AIRGAP-56-001, NOTIFY-OBS-51-001 | Emit notifications when policy packs near staleness thresholds or missing required bundles. | Notifications dispatched with remediation; CLI/Console show consistent warnings; integration tests cover thresholds. |

View File

@@ -0,0 +1,35 @@
using System;
using System.Threading;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Hosting;
using StellaOps.Policy.Engine.Options;
namespace StellaOps.Policy.Engine.Workers;
internal sealed class PolicyEngineBootstrapWorker : BackgroundService
{
private readonly ILogger<PolicyEngineBootstrapWorker> logger;
private readonly PolicyEngineStartupDiagnostics diagnostics;
private readonly PolicyEngineOptions options;
public PolicyEngineBootstrapWorker(
ILogger<PolicyEngineBootstrapWorker> logger,
PolicyEngineStartupDiagnostics diagnostics,
PolicyEngineOptions options)
{
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
this.options = options ?? throw new ArgumentNullException(nameof(options));
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Policy Engine bootstrap worker started. Authority issuer: {AuthorityIssuer}. Database: {Database}.",
options.Authority.Issuer,
options.Storage.DatabaseName);
diagnostics.MarkReady();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,15 @@
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.Policy.Gateway.Infrastructure;
namespace StellaOps.Policy.Gateway.Clients;
internal interface IPolicyEngineClient
{
Task<PolicyEngineResponse<IReadOnlyList<PolicyPackSummaryDto>>> ListPolicyPacksAsync(GatewayForwardingContext? forwardingContext, CancellationToken cancellationToken);
Task<PolicyEngineResponse<PolicyPackDto>> CreatePolicyPackAsync(GatewayForwardingContext? forwardingContext, CreatePolicyPackRequest request, CancellationToken cancellationToken);
Task<PolicyEngineResponse<PolicyRevisionDto>> CreatePolicyRevisionAsync(GatewayForwardingContext? forwardingContext, string packId, CreatePolicyRevisionRequest request, CancellationToken cancellationToken);
Task<PolicyEngineResponse<PolicyRevisionActivationDto>> ActivatePolicyRevisionAsync(GatewayForwardingContext? forwardingContext, string packId, int version, ActivatePolicyRevisionRequest request, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.Policy.Gateway.Infrastructure;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
namespace StellaOps.Policy.Gateway.Clients;
internal sealed class PolicyEngineClient : IPolicyEngineClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
private readonly HttpClient httpClient;
private readonly PolicyEngineTokenProvider tokenProvider;
private readonly ILogger<PolicyEngineClient> logger;
private readonly PolicyGatewayOptions options;
public PolicyEngineClient(
HttpClient httpClient,
IOptions<PolicyGatewayOptions> options,
PolicyEngineTokenProvider tokenProvider,
ILogger<PolicyEngineClient> logger)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
this.options = options.Value ?? throw new InvalidOperationException("Policy Gateway options must be configured.");
if (httpClient.BaseAddress is null)
{
httpClient.BaseAddress = this.options.PolicyEngine.BaseUri;
}
httpClient.DefaultRequestHeaders.Accept.Clear();
httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
}
public Task<PolicyEngineResponse<IReadOnlyList<PolicyPackSummaryDto>>> ListPolicyPacksAsync(
GatewayForwardingContext? forwardingContext,
CancellationToken cancellationToken)
=> SendAsync<IReadOnlyList<PolicyPackSummaryDto>>(
HttpMethod.Get,
"api/policy/packs",
forwardingContext,
content: null,
cancellationToken);
public Task<PolicyEngineResponse<PolicyPackDto>> CreatePolicyPackAsync(
GatewayForwardingContext? forwardingContext,
CreatePolicyPackRequest request,
CancellationToken cancellationToken)
=> SendAsync<PolicyPackDto>(
HttpMethod.Post,
"api/policy/packs",
forwardingContext,
request,
cancellationToken);
public Task<PolicyEngineResponse<PolicyRevisionDto>> CreatePolicyRevisionAsync(
GatewayForwardingContext? forwardingContext,
string packId,
CreatePolicyRevisionRequest request,
CancellationToken cancellationToken)
=> SendAsync<PolicyRevisionDto>(
HttpMethod.Post,
$"api/policy/packs/{Uri.EscapeDataString(packId)}/revisions",
forwardingContext,
request,
cancellationToken);
public Task<PolicyEngineResponse<PolicyRevisionActivationDto>> ActivatePolicyRevisionAsync(
GatewayForwardingContext? forwardingContext,
string packId,
int version,
ActivatePolicyRevisionRequest request,
CancellationToken cancellationToken)
=> SendAsync<PolicyRevisionActivationDto>(
HttpMethod.Post,
$"api/policy/packs/{Uri.EscapeDataString(packId)}/revisions/{version}:activate",
forwardingContext,
request,
cancellationToken);
private async Task<PolicyEngineResponse<TSuccess>> SendAsync<TSuccess>(
HttpMethod method,
string relativeUri,
GatewayForwardingContext? forwardingContext,
object? content,
CancellationToken cancellationToken)
{
var absoluteUri = httpClient.BaseAddress is not null
? new Uri(httpClient.BaseAddress, relativeUri)
: new Uri(relativeUri, UriKind.Absolute);
using var request = new HttpRequestMessage(method, absoluteUri);
if (forwardingContext is not null)
{
forwardingContext.Apply(request);
}
else
{
var serviceAuthorization = await tokenProvider.GetAuthorizationAsync(method, absoluteUri, cancellationToken).ConfigureAwait(false);
if (serviceAuthorization is null)
{
logger.LogWarning(
"Policy Engine request {Method} {Uri} lacks caller credentials and client credentials flow is disabled.",
method,
absoluteUri);
var problem = new ProblemDetails
{
Title = "Upstream authorization missing",
Detail = "Caller did not present credentials and client credentials flow is disabled.",
Status = StatusCodes.Status401Unauthorized
};
return PolicyEngineResponse<TSuccess>.Failure(HttpStatusCode.Unauthorized, problem);
}
var authorization = serviceAuthorization.Value;
authorization.Apply(request);
}
if (content is not null)
{
request.Content = JsonContent.Create(content, options: SerializerOptions);
}
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var location = response.Headers.Location?.ToString();
if (response.IsSuccessStatusCode)
{
if (response.Content is null || response.Content.Headers.ContentLength == 0)
{
return PolicyEngineResponse<TSuccess>.Success(response.StatusCode, value: default, location);
}
try
{
var successValue = await response.Content.ReadFromJsonAsync<TSuccess>(SerializerOptions, cancellationToken).ConfigureAwait(false);
return PolicyEngineResponse<TSuccess>.Success(response.StatusCode, successValue, location);
}
catch (JsonException ex)
{
logger.LogError(ex, "Failed to deserialize Policy Engine response for {Path}.", relativeUri);
var problem = new ProblemDetails
{
Title = "Invalid upstream response",
Detail = "Policy Engine returned an unexpected payload.",
Status = StatusCodes.Status502BadGateway
};
return PolicyEngineResponse<TSuccess>.Failure(HttpStatusCode.BadGateway, problem);
}
}
var problemDetails = await ReadProblemDetailsAsync(response, cancellationToken).ConfigureAwait(false);
return PolicyEngineResponse<TSuccess>.Failure(response.StatusCode, problemDetails);
}
private async Task<ProblemDetails?> ReadProblemDetailsAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.Content is null)
{
return null;
}
try
{
return await response.Content.ReadFromJsonAsync<ProblemDetails>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
logger.LogDebug(ex, "Policy Engine returned non-ProblemDetails error response for {StatusCode}.", (int)response.StatusCode);
return new ProblemDetails
{
Title = "Upstream error",
Detail = $"Policy Engine responded with {(int)response.StatusCode} {response.ReasonPhrase}.",
Status = (int)response.StatusCode
};
}
}
}

View File

@@ -0,0 +1,31 @@
using System.Net;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.Policy.Gateway.Clients;
internal sealed class PolicyEngineResponse<TSuccess>
{
private PolicyEngineResponse(HttpStatusCode statusCode, TSuccess? value, ProblemDetails? problem, string? location)
{
StatusCode = statusCode;
Value = value;
Problem = problem;
Location = location;
}
public HttpStatusCode StatusCode { get; }
public TSuccess? Value { get; }
public ProblemDetails? Problem { get; }
public string? Location { get; }
public bool IsSuccess => Problem is null && StatusCode is >= HttpStatusCode.OK and < HttpStatusCode.MultipleChoices;
public static PolicyEngineResponse<TSuccess> Success(HttpStatusCode statusCode, TSuccess? value, string? location)
=> new(statusCode, value, problem: null, location);
public static PolicyEngineResponse<TSuccess> Failure(HttpStatusCode statusCode, ProblemDetails? problem)
=> new(statusCode, value: default, problem, location: null);
}

View File

@@ -0,0 +1,71 @@
using System;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.Policy.Gateway.Clients;
internal static class PolicyEngineResponseExtensions
{
public static IResult ToMinimalResult<T>(this PolicyEngineResponse<T> response)
{
if (response is null)
{
throw new ArgumentNullException(nameof(response));
}
if (response.IsSuccess)
{
return CreateSuccessResult(response);
}
return CreateErrorResult(response);
}
private static IResult CreateSuccessResult<T>(PolicyEngineResponse<T> response)
{
var value = response.Value;
switch (response.StatusCode)
{
case HttpStatusCode.Created:
if (!string.IsNullOrWhiteSpace(response.Location))
{
return Results.Created(response.Location, value);
}
return Results.Json(value, statusCode: StatusCodes.Status201Created);
case HttpStatusCode.Accepted:
if (!string.IsNullOrWhiteSpace(response.Location))
{
return Results.Accepted(response.Location, value);
}
return Results.Json(value, statusCode: StatusCodes.Status202Accepted);
case HttpStatusCode.NoContent:
return Results.NoContent();
default:
return Results.Json(value, statusCode: (int)response.StatusCode);
}
}
private static IResult CreateErrorResult<T>(PolicyEngineResponse<T> response)
{
var problem = response.Problem;
if (problem is null)
{
return Results.StatusCode((int)response.StatusCode);
}
var statusCode = problem.Status ?? (int)response.StatusCode;
return Results.Problem(
title: problem.Title,
detail: problem.Detail,
type: problem.Type,
instance: problem.Instance,
statusCode: statusCode,
extensions: problem.Extensions);
}
}

View File

@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Policy.Gateway.Contracts;
public sealed record PolicyPackSummaryDto(
string PackId,
string? DisplayName,
DateTimeOffset CreatedAt,
IReadOnlyList<int> Versions);
public sealed record PolicyPackDto(
string PackId,
string? DisplayName,
DateTimeOffset CreatedAt,
IReadOnlyList<PolicyRevisionDto> Revisions);
public sealed record PolicyRevisionDto(
int Version,
string Status,
bool RequiresTwoPersonApproval,
DateTimeOffset CreatedAt,
DateTimeOffset? ActivatedAt,
IReadOnlyList<PolicyActivationApprovalDto> Approvals);
public sealed record PolicyActivationApprovalDto(
string ActorId,
DateTimeOffset ApprovedAt,
string? Comment);
public sealed record PolicyRevisionActivationDto(
string Status,
PolicyRevisionDto Revision);
public sealed record CreatePolicyPackRequest(
[StringLength(200)] string? PackId,
[StringLength(200)] string? DisplayName);
public sealed record CreatePolicyRevisionRequest(
int? Version,
bool RequiresTwoPersonApproval,
string InitialStatus = "Approved");
public sealed record ActivatePolicyRevisionRequest(string? Comment);

View File

@@ -0,0 +1,59 @@
using System;
using System.Net.Http;
using Microsoft.AspNetCore.Http;
namespace StellaOps.Policy.Gateway.Infrastructure;
internal sealed record GatewayForwardingContext(string Authorization, string? Dpop, string? Tenant)
{
private static readonly string[] ForwardedHeaders =
{
"Authorization",
"DPoP",
"X-Stella-Tenant"
};
public void Apply(HttpRequestMessage request)
{
ArgumentNullException.ThrowIfNull(request);
request.Headers.TryAddWithoutValidation(ForwardedHeaders[0], Authorization);
if (!string.IsNullOrWhiteSpace(Dpop))
{
request.Headers.TryAddWithoutValidation(ForwardedHeaders[1], Dpop);
}
if (!string.IsNullOrWhiteSpace(Tenant))
{
request.Headers.TryAddWithoutValidation(ForwardedHeaders[2], Tenant);
}
}
public static bool TryCreate(HttpContext context, out GatewayForwardingContext forwardingContext)
{
ArgumentNullException.ThrowIfNull(context);
var authorization = context.Request.Headers.Authorization.ToString();
if (string.IsNullOrWhiteSpace(authorization))
{
forwardingContext = null!;
return false;
}
var dpop = context.Request.Headers["DPoP"].ToString();
if (string.IsNullOrWhiteSpace(dpop))
{
dpop = null;
}
var tenant = context.Request.Headers["X-Stella-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenant))
{
tenant = null;
}
forwardingContext = new GatewayForwardingContext(authorization.Trim(), dpop, tenant);
return true;
}
}

View File

@@ -0,0 +1,323 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Policy.Gateway.Options;
/// <summary>
/// Root configuration for the Policy Gateway host.
/// </summary>
public sealed class PolicyGatewayOptions
{
public const string SectionName = "PolicyGateway";
public PolicyGatewayTelemetryOptions Telemetry { get; } = new();
public PolicyGatewayResourceServerOptions ResourceServer { get; } = new();
public PolicyGatewayPolicyEngineOptions PolicyEngine { get; } = new();
public void Validate()
{
Telemetry.Validate();
ResourceServer.Validate();
PolicyEngine.Validate();
}
}
/// <summary>
/// Logging and telemetry configuration for the gateway.
/// </summary>
public sealed class PolicyGatewayTelemetryOptions
{
public LogLevel MinimumLogLevel { get; set; } = LogLevel.Information;
public void Validate()
{
if (!Enum.IsDefined(typeof(LogLevel), MinimumLogLevel))
{
throw new InvalidOperationException("Unsupported log level configured for Policy Gateway telemetry.");
}
}
}
/// <summary>
/// JWT resource server configuration for incoming requests handled by the gateway.
/// </summary>
public sealed class PolicyGatewayResourceServerOptions
{
public string Authority { get; set; } = "https://authority.stella-ops.local";
public string? MetadataAddress { get; set; }
= "https://authority.stella-ops.local/.well-known/openid-configuration";
public IList<string> Audiences { get; } = new List<string> { "api://policy-gateway" };
public IList<string> RequiredScopes { get; } = new List<string>
{
StellaOpsScopes.PolicyRead,
StellaOpsScopes.PolicyAuthor,
StellaOpsScopes.PolicyReview,
StellaOpsScopes.PolicyApprove,
StellaOpsScopes.PolicyOperate,
StellaOpsScopes.PolicySimulate,
StellaOpsScopes.PolicyRun,
StellaOpsScopes.PolicyActivate
};
public IList<string> RequiredTenants { get; } = new List<string>();
public IList<string> BypassNetworks { get; } = new List<string> { "127.0.0.1/32", "::1/128" };
public bool RequireHttpsMetadata { get; set; } = true;
public int BackchannelTimeoutSeconds { get; set; } = 30;
public int TokenClockSkewSeconds { get; set; } = 60;
public void Validate()
{
if (string.IsNullOrWhiteSpace(Authority))
{
throw new InvalidOperationException("Policy Gateway resource server configuration requires an Authority URL.");
}
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var authorityUri))
{
throw new InvalidOperationException("Policy Gateway resource server Authority URL must be absolute.");
}
if (RequireHttpsMetadata &&
!authorityUri.IsLoopback &&
!string.Equals(authorityUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Policy Gateway resource server Authority URL must use HTTPS when metadata requires HTTPS.");
}
if (BackchannelTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Policy Gateway resource server back-channel timeout must be greater than zero seconds.");
}
if (TokenClockSkewSeconds < 0 || TokenClockSkewSeconds > 300)
{
throw new InvalidOperationException("Policy Gateway resource server token clock skew must be between 0 and 300 seconds.");
}
NormalizeList(Audiences, toLower: false);
NormalizeList(RequiredScopes, toLower: true);
NormalizeList(RequiredTenants, toLower: true);
NormalizeList(BypassNetworks, toLower: false);
}
private static void NormalizeList(IList<string> values, bool toLower)
{
if (values.Count == 0)
{
return;
}
var unique = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = values.Count - 1; index >= 0; index--)
{
var value = values[index];
if (string.IsNullOrWhiteSpace(value))
{
values.RemoveAt(index);
continue;
}
var normalized = value.Trim();
if (toLower)
{
normalized = normalized.ToLowerInvariant();
}
if (!unique.Add(normalized))
{
values.RemoveAt(index);
continue;
}
values[index] = normalized;
}
}
}
/// <summary>
/// Outbound Policy Engine configuration used by the gateway to forward requests.
/// </summary>
public sealed class PolicyGatewayPolicyEngineOptions
{
public string BaseAddress { get; set; } = "https://policy-engine.stella-ops.local";
public string Audience { get; set; } = "api://policy-engine";
public PolicyGatewayClientCredentialsOptions ClientCredentials { get; } = new();
public PolicyGatewayDpopOptions Dpop { get; } = new();
public void Validate()
{
if (string.IsNullOrWhiteSpace(BaseAddress))
{
throw new InvalidOperationException("Policy Gateway requires a Policy Engine base address.");
}
if (!Uri.TryCreate(BaseAddress.Trim(), UriKind.Absolute, out var baseUri))
{
throw new InvalidOperationException("Policy Gateway Policy Engine base address must be an absolute URI.");
}
if (!string.Equals(baseUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && !baseUri.IsLoopback)
{
throw new InvalidOperationException("Policy Gateway Policy Engine base address must use HTTPS unless targeting loopback.");
}
if (string.IsNullOrWhiteSpace(Audience))
{
throw new InvalidOperationException("Policy Gateway requires a Policy Engine audience value for client credential flows.");
}
ClientCredentials.Validate();
Dpop.Validate();
}
public Uri BaseUri => new(BaseAddress, UriKind.Absolute);
}
/// <summary>
/// Client credential configuration for the gateway when calling the Policy Engine.
/// </summary>
public sealed class PolicyGatewayClientCredentialsOptions
{
public bool Enabled { get; set; } = true;
public string ClientId { get; set; } = "policy-gateway";
public string? ClientSecret { get; set; }
= "change-me";
public IList<string> Scopes { get; } = new List<string>
{
StellaOpsScopes.PolicyRead,
StellaOpsScopes.PolicyAuthor,
StellaOpsScopes.PolicyReview,
StellaOpsScopes.PolicyApprove,
StellaOpsScopes.PolicyOperate,
StellaOpsScopes.PolicySimulate,
StellaOpsScopes.PolicyRun,
StellaOpsScopes.PolicyActivate
};
public int BackchannelTimeoutSeconds { get; set; } = 30;
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(ClientId))
{
throw new InvalidOperationException("Policy Gateway client credential configuration requires a client identifier when enabled.");
}
if (Scopes.Count == 0)
{
throw new InvalidOperationException("Policy Gateway client credential configuration requires at least one scope when enabled.");
}
var normalized = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = Scopes.Count - 1; index >= 0; index--)
{
var scope = Scopes[index];
if (string.IsNullOrWhiteSpace(scope))
{
Scopes.RemoveAt(index);
continue;
}
var trimmed = scope.Trim().ToLowerInvariant();
if (!normalized.Add(trimmed))
{
Scopes.RemoveAt(index);
continue;
}
Scopes[index] = trimmed;
}
if (Scopes.Count == 0)
{
throw new InvalidOperationException("Policy Gateway client credential configuration requires at least one non-empty scope when enabled.");
}
if (BackchannelTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Policy Gateway client credential back-channel timeout must be greater than zero seconds.");
}
}
public IReadOnlyList<string> NormalizedScopes => new ReadOnlyCollection<string>(Scopes);
public TimeSpan BackchannelTimeout => TimeSpan.FromSeconds(BackchannelTimeoutSeconds);
}
/// <summary>
/// DPoP sender-constrained credential configuration for outbound Policy Engine calls.
/// </summary>
public sealed class PolicyGatewayDpopOptions
{
public bool Enabled { get; set; } = false;
public string KeyPath { get; set; } = string.Empty;
public string? KeyPassphrase { get; set; }
= null;
public string Algorithm { get; set; } = "ES256";
public TimeSpan ProofLifetime { get; set; } = TimeSpan.FromMinutes(2);
public TimeSpan ClockSkew { get; set; } = TimeSpan.FromSeconds(30);
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(KeyPath))
{
throw new InvalidOperationException("Policy Gateway DPoP configuration requires a key path when enabled.");
}
if (string.IsNullOrWhiteSpace(Algorithm))
{
throw new InvalidOperationException("Policy Gateway DPoP configuration requires an algorithm when enabled.");
}
var normalizedAlgorithm = Algorithm.Trim().ToUpperInvariant();
if (normalizedAlgorithm is not ("ES256" or "ES384"))
{
throw new InvalidOperationException("Policy Gateway DPoP configuration supports only ES256 or ES384 algorithms.");
}
if (ProofLifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("Policy Gateway DPoP proof lifetime must be greater than zero.");
}
if (ClockSkew < TimeSpan.Zero || ClockSkew > TimeSpan.FromMinutes(5))
{
throw new InvalidOperationException("Policy Gateway DPoP clock skew must be between 0 seconds and 5 minutes.");
}
Algorithm = normalizedAlgorithm;
}
}

View File

@@ -0,0 +1,406 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NetEscapades.Configuration.Yaml;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Policy.Gateway.Clients;
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.Policy.Gateway.Infrastructure;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
using Polly;
using Polly.Extensions.Http;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole();
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "STELLAOPS_POLICY_GATEWAY_";
options.ConfigureBuilder = configurationBuilder =>
{
var contentRoot = builder.Environment.ContentRootPath;
foreach (var relative in new[]
{
"../etc/policy-gateway.yaml",
"../etc/policy-gateway.local.yaml",
"policy-gateway.yaml",
"policy-gateway.local.yaml"
})
{
var path = Path.Combine(contentRoot, relative);
configurationBuilder.AddYamlFile(path, optional: true);
}
};
});
var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyGatewayOptions>(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "STELLAOPS_POLICY_GATEWAY_";
options.BindingSection = PolicyGatewayOptions.SectionName;
options.ConfigureBuilder = configurationBuilder =>
{
foreach (var relative in new[]
{
"../etc/policy-gateway.yaml",
"../etc/policy-gateway.local.yaml",
"policy-gateway.yaml",
"policy-gateway.local.yaml"
})
{
var path = Path.Combine(builder.Environment.ContentRootPath, relative);
configurationBuilder.AddYamlFile(path, optional: true);
}
};
options.PostBind = static (value, _) => value.Validate();
});
builder.Configuration.AddConfiguration(bootstrap.Configuration);
builder.Logging.SetMinimumLevel(bootstrap.Options.Telemetry.MinimumLogLevel);
builder.Services.AddOptions<PolicyGatewayOptions>()
.Bind(builder.Configuration.GetSection(PolicyGatewayOptions.SectionName))
.Validate(options =>
{
try
{
options.Validate();
return true;
}
catch (Exception ex)
{
throw new OptionsValidationException(
PolicyGatewayOptions.SectionName,
typeof(PolicyGatewayOptions),
new[] { ex.Message });
}
})
.ValidateOnStart();
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
builder.Services.AddStellaOpsScopeHandler();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: $"{PolicyGatewayOptions.SectionName}:ResourceServer");
builder.Services.AddSingleton<PolicyGatewayMetrics>();
builder.Services.AddSingleton<PolicyGatewayDpopProofGenerator>();
builder.Services.AddSingleton<PolicyEngineTokenProvider>();
builder.Services.AddTransient<PolicyGatewayDpopHandler>();
if (bootstrap.Options.PolicyEngine.ClientCredentials.Enabled)
{
builder.Services.AddOptions<StellaOpsAuthClientOptions>()
.Configure(options =>
{
options.Authority = bootstrap.Options.ResourceServer.Authority;
options.ClientId = bootstrap.Options.PolicyEngine.ClientCredentials.ClientId;
options.ClientSecret = bootstrap.Options.PolicyEngine.ClientCredentials.ClientSecret;
options.HttpTimeout = TimeSpan.FromSeconds(bootstrap.Options.PolicyEngine.ClientCredentials.BackchannelTimeoutSeconds);
foreach (var scope in bootstrap.Options.PolicyEngine.ClientCredentials.Scopes)
{
options.DefaultScopes.Add(scope);
}
})
.PostConfigure(static opt => opt.Validate());
builder.Services.TryAddSingleton<IStellaOpsTokenCache, InMemoryTokenCache>();
builder.Services.AddHttpClient<StellaOpsDiscoveryCache>((provider, client) =>
{
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
client.Timeout = authOptions.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider));
builder.Services.AddHttpClient<StellaOpsJwksCache>((provider, client) =>
{
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
client.Timeout = authOptions.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider));
builder.Services.AddHttpClient<IStellaOpsTokenClient, StellaOpsTokenClient>((provider, client) =>
{
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
client.Timeout = authOptions.HttpTimeout;
})
.AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider))
.AddHttpMessageHandler<PolicyGatewayDpopHandler>();
}
builder.Services.AddHttpClient<IPolicyEngineClient, PolicyEngineClient>((serviceProvider, client) =>
{
var gatewayOptions = serviceProvider.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value;
client.BaseAddress = gatewayOptions.PolicyEngine.BaseUri;
client.Timeout = TimeSpan.FromSeconds(gatewayOptions.PolicyEngine.ClientCredentials.BackchannelTimeoutSeconds);
})
.AddPolicyHandler(static (provider, _) => CreatePolicyEngineRetryPolicy(provider));
var app = builder.Build();
app.UseExceptionHandler(static appBuilder => appBuilder.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new { error = "Unexpected gateway error." });
}));
app.UseStatusCodePages();
app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/healthz");
app.MapGet("/readyz", () => Results.Ok(new { status = "ready" }))
.WithName("Readiness");
app.MapGet("/", () => Results.Redirect("/healthz"));
var policyPacks = app.MapGroup("/api/policy/packs")
.WithTags("Policy Packs");
policyPacks.MapGet(string.Empty, async Task<IResult> (
HttpContext context,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
CancellationToken cancellationToken) =>
{
GatewayForwardingContext? forwardingContext = null;
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var response = await client.ListPolicyPacksAsync(forwardingContext, cancellationToken).ConfigureAwait(false);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
policyPacks.MapPost(string.Empty, async Task<IResult> (
HttpContext context,
CreatePolicyPackRequest request,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
CancellationToken cancellationToken) =>
{
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required.",
Status = StatusCodes.Status400BadRequest
});
}
GatewayForwardingContext? forwardingContext = null;
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var response = await client.CreatePolicyPackAsync(forwardingContext, request, cancellationToken).ConfigureAwait(false);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
policyPacks.MapPost("/{packId}/revisions", async Task<IResult> (
HttpContext context,
string packId,
CreatePolicyRevisionRequest request,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(packId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "packId is required.",
Status = StatusCodes.Status400BadRequest
});
}
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required.",
Status = StatusCodes.Status400BadRequest
});
}
GatewayForwardingContext? forwardingContext = null;
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var response = await client.CreatePolicyRevisionAsync(forwardingContext, packId, request, cancellationToken).ConfigureAwait(false);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
policyPacks.MapPost("/{packId}/revisions/{version:int}:activate", async Task<IResult> (
HttpContext context,
string packId,
int version,
ActivatePolicyRevisionRequest request,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
PolicyGatewayMetrics metrics,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(packId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "packId is required.",
Status = StatusCodes.Status400BadRequest
});
}
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required.",
Status = StatusCodes.Status400BadRequest
});
}
GatewayForwardingContext? forwardingContext = null;
var source = "service";
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
source = "caller";
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.ActivatePolicyRevisionAsync(forwardingContext, packId, version, request, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
var outcome = DetermineActivationOutcome(response);
metrics.RecordActivation(outcome, source, stopwatch.Elapsed.TotalMilliseconds);
var logger = loggerFactory.CreateLogger("StellaOps.Policy.Gateway.Activation");
LogActivation(logger, packId, version, outcome, source, response.StatusCode);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
StellaOpsScopes.PolicyOperate,
StellaOpsScopes.PolicyActivate));
app.Run();
static IAsyncPolicy<HttpResponseMessage> CreateAuthorityRetryPolicy(IServiceProvider provider)
{
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
var delays = authOptions.NormalizedRetryDelays;
if (delays.Count == 0)
{
return Policy.NoOpAsync<HttpResponseMessage>();
}
var loggerFactory = provider.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger("PolicyGateway.AuthorityHttp");
return HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(static message => message.StatusCode == HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(
delays.Count,
attempt => delays[attempt - 1],
(outcome, delay, attempt, _) =>
{
logger?.LogWarning(
outcome.Exception,
"Retrying Authority HTTP call ({Attempt}/{Total}) after {Reason}; waiting {Delay}.",
attempt,
delays.Count,
outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString(),
delay);
});
}
static IAsyncPolicy<HttpResponseMessage> CreatePolicyEngineRetryPolicy(IServiceProvider provider)
=> HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(static response => response.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout)
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));
static string DetermineActivationOutcome(PolicyEngineResponse<PolicyRevisionActivationDto> response)
{
if (response.IsSuccess)
{
return response.Value?.Status switch
{
"activated" => "activated",
"already_active" => "already_active",
"pending_second_approval" => "pending_second_approval",
_ => "success"
};
}
return response.StatusCode switch
{
HttpStatusCode.BadRequest => "bad_request",
HttpStatusCode.NotFound => "not_found",
HttpStatusCode.Unauthorized => "unauthorized",
HttpStatusCode.Forbidden => "forbidden",
_ => "error"
};
}
static void LogActivation(ILogger logger, string packId, int version, string outcome, string source, HttpStatusCode statusCode)
{
if (logger is null)
{
return;
}
var message = "Policy activation forwarded.";
var logLevel = outcome is "activated" or "already_active" or "pending_second_approval" ? LogLevel.Information : LogLevel.Warning;
logger.Log(logLevel, message + " Outcome={Outcome}; Source={Source}; PackId={PackId}; Version={Version}; StatusCode={StatusCode}.", outcome, source, packId, version, (int)statusCode);
}
public partial class Program
{
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Policy.Gateway.Tests")]

View File

@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Client;
using StellaOps.Policy.Gateway.Options;
namespace StellaOps.Policy.Gateway.Services;
internal sealed class PolicyEngineTokenProvider
{
private readonly IStellaOpsTokenClient tokenClient;
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
private readonly PolicyGatewayDpopProofGenerator dpopGenerator;
private readonly TimeProvider timeProvider;
private readonly ILogger<PolicyEngineTokenProvider> logger;
private readonly SemaphoreSlim mutex = new(1, 1);
private CachedToken? cachedToken;
public PolicyEngineTokenProvider(
IStellaOpsTokenClient tokenClient,
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
PolicyGatewayDpopProofGenerator dpopGenerator,
TimeProvider timeProvider,
ILogger<PolicyEngineTokenProvider> logger)
{
this.tokenClient = tokenClient ?? throw new ArgumentNullException(nameof(tokenClient));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.dpopGenerator = dpopGenerator ?? throw new ArgumentNullException(nameof(dpopGenerator));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public bool IsEnabled => optionsMonitor.CurrentValue.PolicyEngine.ClientCredentials.Enabled;
public async ValueTask<PolicyGatewayAuthorization?> GetAuthorizationAsync(HttpMethod method, Uri targetUri, CancellationToken cancellationToken)
{
if (!IsEnabled)
{
return null;
}
var tokenResult = await GetTokenAsync(cancellationToken).ConfigureAwait(false);
if (tokenResult is null)
{
return null;
}
var token = tokenResult.Value;
string? proof = null;
if (dpopGenerator.Enabled)
{
proof = dpopGenerator.CreateProof(method, targetUri, token.AccessToken);
}
var scheme = string.Equals(token.TokenType, "dpop", StringComparison.OrdinalIgnoreCase)
? "DPoP"
: token.TokenType;
var authorization = $"{scheme} {token.AccessToken}";
return new PolicyGatewayAuthorization(authorization, proof, "service");
}
private async ValueTask<CachedToken?> GetTokenAsync(CancellationToken cancellationToken)
{
var options = optionsMonitor.CurrentValue.PolicyEngine;
if (!options.ClientCredentials.Enabled)
{
return null;
}
var now = timeProvider.GetUtcNow();
if (cachedToken is { } existing && existing.ExpiresAt > now + TimeSpan.FromSeconds(30))
{
return existing;
}
await mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (cachedToken is { } cached && cached.ExpiresAt > now + TimeSpan.FromSeconds(30))
{
return cached;
}
var scopeString = BuildScopeClaim(options);
var result = await tokenClient.RequestClientCredentialsTokenAsync(scopeString, null, cancellationToken).ConfigureAwait(false);
var expiresAt = result.ExpiresAtUtc;
cachedToken = new CachedToken(result.AccessToken, string.IsNullOrWhiteSpace(result.TokenType) ? "Bearer" : result.TokenType, expiresAt);
logger.LogInformation("Issued Policy Engine client credentials token; expires at {ExpiresAt:o}.", expiresAt);
return cachedToken;
}
finally
{
mutex.Release();
}
}
private string BuildScopeClaim(PolicyGatewayPolicyEngineOptions options)
{
var scopeSet = new SortedSet<string>(StringComparer.Ordinal)
{
$"aud:{options.Audience.Trim().ToLowerInvariant()}"
};
foreach (var scope in options.ClientCredentials.Scopes)
{
if (string.IsNullOrWhiteSpace(scope))
{
continue;
}
scopeSet.Add(scope.Trim());
}
return string.Join(' ', scopeSet);
}
private readonly record struct CachedToken(string AccessToken, string TokenType, DateTimeOffset ExpiresAt);
}

View File

@@ -0,0 +1,24 @@
using System;
using System.Net.Http;
namespace StellaOps.Policy.Gateway.Services;
internal readonly record struct PolicyGatewayAuthorization(string AuthorizationHeader, string? DpopProof, string Source)
{
public void Apply(HttpRequestMessage request)
{
ArgumentNullException.ThrowIfNull(request);
if (!string.IsNullOrWhiteSpace(AuthorizationHeader))
{
request.Headers.Remove("Authorization");
request.Headers.TryAddWithoutValidation("Authorization", AuthorizationHeader);
}
if (!string.IsNullOrWhiteSpace(DpopProof))
{
request.Headers.Remove("DPoP");
request.Headers.TryAddWithoutValidation("DPoP", DpopProof);
}
}
}

View File

@@ -0,0 +1,42 @@
using System;
using System.Net.Http;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Gateway.Options;
namespace StellaOps.Policy.Gateway.Services;
internal sealed class PolicyGatewayDpopHandler : DelegatingHandler
{
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
private readonly PolicyGatewayDpopProofGenerator proofGenerator;
public PolicyGatewayDpopHandler(
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
PolicyGatewayDpopProofGenerator proofGenerator)
{
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.proofGenerator = proofGenerator ?? throw new ArgumentNullException(nameof(proofGenerator));
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
if (options.Enabled &&
proofGenerator.Enabled &&
request.Method == HttpMethod.Post &&
request.RequestUri is { } uri &&
uri.AbsolutePath.Contains("/token", StringComparison.OrdinalIgnoreCase))
{
var proof = proofGenerator.CreateProof(request.Method, uri, accessToken: null);
request.Headers.Remove("DPoP");
request.Headers.TryAddWithoutValidation("DPoP", proof);
}
return base.SendAsync(request, cancellationToken);
}
}

View File

@@ -0,0 +1,235 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.IO;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using StellaOps.Policy.Gateway.Options;
namespace StellaOps.Policy.Gateway.Services;
internal sealed class PolicyGatewayDpopProofGenerator : IDisposable
{
private readonly IHostEnvironment hostEnvironment;
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
private readonly TimeProvider timeProvider;
private readonly ILogger<PolicyGatewayDpopProofGenerator> logger;
private DpopKeyMaterial? keyMaterial;
private readonly object sync = new();
public PolicyGatewayDpopProofGenerator(
IHostEnvironment hostEnvironment,
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
TimeProvider timeProvider,
ILogger<PolicyGatewayDpopProofGenerator> logger)
{
this.hostEnvironment = hostEnvironment ?? throw new ArgumentNullException(nameof(hostEnvironment));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public bool Enabled
{
get
{
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
return options.Enabled;
}
}
public string CreateProof(HttpMethod method, Uri targetUri, string? accessToken)
{
ArgumentNullException.ThrowIfNull(method);
ArgumentNullException.ThrowIfNull(targetUri);
if (!Enabled)
{
throw new InvalidOperationException("DPoP proof requested while DPoP is disabled.");
}
var material = GetOrLoadKeyMaterial();
var header = CreateHeader(material);
var payload = CreatePayload(method, targetUri, accessToken);
var jwt = new JwtSecurityToken(header, payload);
var handler = new JwtSecurityTokenHandler();
return handler.WriteToken(jwt);
}
private JwtHeader CreateHeader(DpopKeyMaterial material)
{
var header = new JwtHeader(new SigningCredentials(material.SecurityKey, material.SigningAlgorithm));
header["typ"] = "dpop+jwt";
header["jwk"] = new Dictionary<string, object>
{
["kty"] = material.Jwk.Kty,
["crv"] = material.Jwk.Crv,
["x"] = material.Jwk.X,
["y"] = material.Jwk.Y,
["kid"] = material.Jwk.Kid
};
return header;
}
private JwtPayload CreatePayload(HttpMethod method, Uri targetUri, string? accessToken)
{
var now = timeProvider.GetUtcNow();
var epochSeconds = (long)Math.Floor((now - DateTimeOffset.UnixEpoch).TotalSeconds);
var payload = new JwtPayload
{
["htm"] = method.Method.ToUpperInvariant(),
["htu"] = NormalizeTarget(targetUri),
["iat"] = epochSeconds,
["jti"] = Guid.NewGuid().ToString("N")
};
if (!string.IsNullOrWhiteSpace(accessToken))
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(accessToken));
payload["ath"] = Base64UrlEncoder.Encode(hash);
}
return payload;
}
private static string NormalizeTarget(Uri uri)
{
if (!uri.IsAbsoluteUri)
{
throw new InvalidOperationException("DPoP proofs require absolute target URIs.");
}
return uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped);
}
private DpopKeyMaterial GetOrLoadKeyMaterial()
{
if (keyMaterial is not null)
{
return keyMaterial;
}
lock (sync)
{
if (keyMaterial is not null)
{
return keyMaterial;
}
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
if (!options.Enabled)
{
throw new InvalidOperationException("DPoP is not enabled in the current configuration.");
}
var resolvedPath = ResolveKeyPath(options.KeyPath);
if (!File.Exists(resolvedPath))
{
throw new FileNotFoundException($"DPoP key file not found at '{resolvedPath}'.", resolvedPath);
}
var pem = File.ReadAllText(resolvedPath);
ECDsa ecdsa;
try
{
ecdsa = ECDsa.Create();
if (!string.IsNullOrWhiteSpace(options.KeyPassphrase))
{
ecdsa.ImportFromEncryptedPem(pem, options.KeyPassphrase);
}
else
{
ecdsa.ImportFromPem(pem);
}
}
catch (Exception ex)
{
throw new InvalidOperationException("Failed to load DPoP private key.", ex);
}
var securityKey = new ECDsaSecurityKey(ecdsa)
{
KeyId = ComputeKeyId(ecdsa)
};
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey);
jwk.Kid ??= securityKey.KeyId;
keyMaterial = new DpopKeyMaterial(ecdsa, securityKey, jwk, MapAlgorithm(options.Algorithm));
logger.LogInformation("Loaded DPoP key from {Path} (alg: {Algorithm}).", resolvedPath, options.Algorithm);
return keyMaterial;
}
}
private string ResolveKeyPath(string path)
{
if (Path.IsPathRooted(path))
{
return path;
}
return Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, path));
}
private static string ComputeKeyId(ECDsa ecdsa)
{
var parameters = ecdsa.ExportParameters(includePrivateParameters: false);
var buffer = new byte[(parameters.Q.X?.Length ?? 0) + (parameters.Q.Y?.Length ?? 0)];
var offset = 0;
if (parameters.Q.X is not null)
{
Buffer.BlockCopy(parameters.Q.X, 0, buffer, offset, parameters.Q.X.Length);
offset += parameters.Q.X.Length;
}
if (parameters.Q.Y is not null)
{
Buffer.BlockCopy(parameters.Q.Y, 0, buffer, offset, parameters.Q.Y.Length);
}
var hash = SHA256.HashData(buffer);
return Base64UrlEncoder.Encode(hash);
}
private static string MapAlgorithm(string algorithm)
=> algorithm switch
{
"ES256" => SecurityAlgorithms.EcdsaSha256,
"ES384" => SecurityAlgorithms.EcdsaSha384,
_ => throw new InvalidOperationException($"Unsupported DPoP signing algorithm '{algorithm}'.")
};
public void Dispose()
{
if (keyMaterial is { } material)
{
material.Dispose();
}
}
private sealed class DpopKeyMaterial : IDisposable
{
public DpopKeyMaterial(ECDsa ecdsa, ECDsaSecurityKey securityKey, JsonWebKey jwk, string signingAlgorithm)
{
Ecdsa = ecdsa;
SecurityKey = securityKey;
Jwk = jwk;
SigningAlgorithm = signingAlgorithm;
}
public ECDsa Ecdsa { get; }
public ECDsaSecurityKey SecurityKey { get; }
public JsonWebKey Jwk { get; }
public string SigningAlgorithm { get; }
public void Dispose()
{
Ecdsa.Dispose();
}
}
}

View File

@@ -0,0 +1,51 @@
using System;
using System.Diagnostics.Metrics;
namespace StellaOps.Policy.Gateway.Services;
internal sealed class PolicyGatewayMetrics : IDisposable
{
private static readonly KeyValuePair<string, object?>[] EmptyTags = Array.Empty<KeyValuePair<string, object?>>();
private readonly Meter meter;
public PolicyGatewayMetrics()
{
meter = new Meter("StellaOps.Policy.Gateway", "1.0.0");
ActivationRequests = meter.CreateCounter<long>(
"policy_gateway_activation_requests_total",
unit: "count",
description: "Total policy activation proxy requests processed by the gateway.");
ActivationLatencyMs = meter.CreateHistogram<double>(
"policy_gateway_activation_latency_ms",
unit: "ms",
description: "Latency distribution for policy activation proxy calls.");
}
public Counter<long> ActivationRequests { get; }
public Histogram<double> ActivationLatencyMs { get; }
public void RecordActivation(string outcome, string source, double elapsedMilliseconds)
{
var tags = BuildTags(outcome, source);
ActivationRequests.Add(1, tags);
ActivationLatencyMs.Record(elapsedMilliseconds, tags);
}
private static KeyValuePair<string, object?>[] BuildTags(string outcome, string source)
{
outcome = string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome;
source = string.IsNullOrWhiteSpace(source) ? "unspecified" : source;
return new[]
{
new KeyValuePair<string, object?>("outcome", outcome),
new KeyValuePair<string, object?>("source", source)
};
}
public void Dispose()
{
meter.Dispose();
}
}

View File

@@ -0,0 +1,23 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,34 @@
# Policy Registry Guild Charter
## Mission
Stand up and operate the Policy Registry service defined in Epic 4. We own workspace storage, version immutability, simulation orchestration metadata, attestations, and RBAC enforcement for the policy lifecycle.
## Scope
- Service source under `src/Policy/StellaOps.Policy.Registry` (REST API, workers, storage schemas).
- Mongo models, migrations, and object storage bindings for policy workspaces, versions, reviews, promotions, simulations.
- Integration with Policy Engine, Scheduler, Authority, Web Gateway, Telemetry.
- Attestation signing pipeline, evidence bundle management, and retention policies.
## Principles
1. **Immutability first** Published versions are append-only; derive new versions rather than mutate.
2. **Determinism** Compilation/simulation requests must produce reproducible artifacts and checksums.
3. **Tenant isolation** Enforce scoping at every storage layer (Mongo collections, buckets, queues).
4. **AOC alignment** Registry stores metadata; it never mutates raw SBOM/advisory/VEX facts.
5. **Auditable** Every transition emits structured events with actor, scope, digest, attestation IDs.
## Collaboration
- Keep `src/Policy/StellaOps.Policy.Registry/TASKS.md`, `../../docs/implplan/SPRINTS.md` synchronized.
- Coordinate API contracts with Policy Engine (`src/Policy/StellaOps.Policy.Engine`), Web Gateway (`src/Web/StellaOps.Web`), Console (`/console`), CLI (`src/Cli/StellaOps.Cli`), and Docs.
- Publish or update OpenAPI specs under `src/Policy/StellaOps.Policy.Registry/openapi/` and hand them to client teams.
## Tooling
- .NET 10 preview (minimal API + background workers).
- MongoDB with per-tenant collections, S3-compatible object storage for bundles.
- Background queue (Scheduler job queue or NATS) for batch simulations.
- Signing via Authority-issued OIDC tokens + cosign integration.
## Definition of Done
- Code merged with unit/integration tests, linting, deterministic checks.
- Telemetry (metrics/logs/traces) wired with tenant context.
- Docs/reference updated; OpenAPI regenerated.
- Feature flags + configuration defaults documented.

View File

@@ -0,0 +1,17 @@
# Policy Registry Task Board — Epic 4: Policy Studio
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| REGISTRY-API-27-001 | TODO | Policy Registry Guild | AUTH-CONSOLE-23-001, POLICY-ENGINE-20-001 | Define OpenAPI specification covering workspaces, versions, reviews, simulations, promotions, and attestations; publish typed clients for Console/CLI. | OpenAPI YAML committed, spectral lint passes, SDK regeneration documented, consumers notified. Docs `DOCS-POLICY-27-001/008/010` waiting on this spec. |
| REGISTRY-API-27-002 | TODO | Policy Registry Guild | REGISTRY-API-27-001 | Implement workspace storage (Mongo collections, object storage buckets) with CRUD endpoints, diff history, and retention policies. | Workspace CRUD passes integration tests; retention job documented; tenancy scopes enforced. |
| REGISTRY-API-27-003 | TODO | Policy Registry Guild | REGISTRY-API-27-002, POLICY-ENGINE-20-001 | Integrate compile endpoint: forward source bundle to Policy Engine, persist diagnostics, symbol table, rule index, and complexity metrics. | Compile API returns diagnostics + symbol table, metrics recorded, failures mapped to `ERR_POL_*`, tests cover success/error cases. |
| REGISTRY-API-27-004 | TODO | Policy Registry Guild | REGISTRY-API-27-003, POLICY-ENGINE-20-002 | Implement quick simulation API with request limits (sample size, timeouts), returning counts, heatmap, sampled explains. | Quick sim enforces limits, results cached with hash, integration tests validate deterministic output. |
| REGISTRY-API-27-005 | TODO | Policy Registry Guild, Scheduler Guild | REGISTRY-API-27-004, SCHED-WORKER-27-301 | Build batch simulation orchestration: enqueue shards, collect partials, reduce deltas, produce evidence bundles + signed manifest. | Batch sim runs end-to-end in staging fixture, manifests stored with checksums, retries/backoff documented. |
> Docs dependency: `DOCS-POLICY-27-004` needs simulation APIs/workers.
| REGISTRY-API-27-006 | TODO | Policy Registry Guild | REGISTRY-API-27-003 | Implement review workflow (comments, votes, required approvers, status transitions) with audit trails and webhooks. | Review endpoints enforce approver quorum, audit log captured, webhook integration tests pass. |
> Docs dependency: `DOCS-POLICY-27-005` waiting on review workflow.
| REGISTRY-API-27-007 | TODO | Policy Registry Guild, Security Guild | REGISTRY-API-27-006, AUTH-POLICY-27-001 | Implement publish pipeline: sign source/compiled digests, create attestations, mark version immutable, emit events. | Published versions immutable, attestations stored & verifiable, metrics/logs emitted, tests cover signing failure. |
> Docs dependency: `DOCS-POLICY-27-003` blocked until publish/sign pipeline ships.
| REGISTRY-API-27-008 | TODO | Policy Registry Guild | REGISTRY-API-27-007, AUTH-POLICY-27-002 | Implement promotion bindings per tenant/environment with canary subsets, rollback path, and environment history. | Promotion API updates bindings atomically, canary percent enforced, rollback recorded, runbooks updated. |
> Docs dependency: `DOCS-POLICY-27-006` requires promotion APIs.
| REGISTRY-API-27-009 | TODO | Policy Registry Guild, Observability Guild | REGISTRY-API-27-002..008 | Instrument metrics/logs/traces (compile time, diagnostics rate, sim queue depth, approval latency) and expose dashboards. | Metrics registered, dashboards seeded, alerts configured, documentation updated. |
| REGISTRY-API-27-010 | TODO | Policy Registry Guild, QA Guild | REGISTRY-API-27-002..008 | Build unit/integration/load test suites for compile/sim/review/publish/promote flows; provide seeded fixtures for CI. | Tests run in CI, load test report documented, determinism checks validated across runs. |

View File

@@ -0,0 +1,15 @@
# Risk Profile Schema Guild Charter
## Mission
Define and maintain the RiskProfile schema, validation rules, inheritance logic, and integration with Policy Engine and Authority scoping.
## Scope
- JSON Schema definition, validators, and code generation for RiskProfile documents.
- Inheritance/merge engine, content hashing, and signature support.
- Policy store integration, scope selectors, and lifecycle management.
- Tooling for Policy Studio and CLI authoring.
## Definition of Done
- Schema publishes via `.well-known/risk-profile-schema` with versioning.
- Validators catch conflicts and produce actionable errors.
- Inheritance and overrides deterministic with tests and golden fixtures.

View File

@@ -0,0 +1,20 @@
# Risk Profile Schema Task Board — Epic 18: Risk Scoring Profiles
## Sprint 66 Schema Foundations
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-RISK-66-001 | TODO | Risk Profile Schema Guild | — | Develop initial JSON Schema for RiskProfile (signals, transforms, weights, severity, overrides) with validator stubs. | Schema published; validators unit-tested with positive/negative fixtures. |
| POLICY-RISK-66-002 | TODO | Risk Profile Schema Guild | POLICY-RISK-66-001 | Implement inheritance/merge logic with conflict detection and deterministic content hashing. | Inheritance tests pass; hashes stable; documentation drafted. |
## Sprint 67 Policy Store Integration
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-RISK-67-001 | TODO | Risk Profile Schema Guild, Policy Engine Guild | POLICY-RISK-66-002 | Integrate profile storage and versioning into Policy Store with lifecycle states (draft/publish/deprecate). | Profiles persisted with status transitions; API returns versioned docs. |
| POLICY-RISK-67-002 | TODO | Risk Profile Schema Guild | POLICY-RISK-67-001 | Publish `.well-known/risk-profile-schema` endpoint and CLI validation tooling. | Endpoint returns schema with version metadata; CLI `stella risk profile validate` uses schema. |
## Sprint 68 Scope & Overrides
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-RISK-68-001 | TODO | Risk Profile Schema Guild, Authority Guild | POLICY-RISK-67-001 | Implement scope selectors, precedence rules, and Authority attachment APIs. | Scope resolution works in tests; conflicts produce clear errors. |
| POLICY-RISK-68-002 | TODO | Risk Profile Schema Guild | POLICY-RISK-66-002 | Add override/adjustment support with audit metadata and validation for conflicting rules. | Overrides validated; golden tests ensure deterministic ordering. |
*** End Task Board ***

View File

@@ -0,0 +1,212 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.Engine", "StellaOps.Policy.Engine\StellaOps.Policy.Engine.csproj", "{83716724-0833-4EB4-BD13-7570DB47148E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{41F15E67-7190-CF23-3BC4-77E87134CADD}"
EndProject
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.Configuration", "..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{E33561D0-D9C4-42F0-A414-CC6439302E5F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{C4F44230-D5FF-425E-BC1B-2ECE59908B59}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{93944DA7-ED8C-466C-90DF-E3522DC49B08}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "..\Authority\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{45287280-FC03-4233-9012-193F4CE41964}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.Gateway", "StellaOps.Policy.Gateway\StellaOps.Policy.Gateway.csproj", "{6B83C5F2-EA81-4723-87EB-99101697B232}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{56BCE1BF-7CBA-7CE8-203D-A88051F1D642}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.Engine.Tests", "__Tests\StellaOps.Policy.Engine.Tests\StellaOps.Policy.Engine.Tests.csproj", "{478DF014-BF69-41BA-B78A-AAC0918337D8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.Gateway.Tests", "__Tests\StellaOps.Policy.Gateway.Tests\StellaOps.Policy.Gateway.Tests.csproj", "{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.Tests", "__Tests\StellaOps.Policy.Tests\StellaOps.Policy.Tests.csproj", "{D064D5C1-3311-470C-92A1-41E913125C14}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{83716724-0833-4EB4-BD13-7570DB47148E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{83716724-0833-4EB4-BD13-7570DB47148E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{83716724-0833-4EB4-BD13-7570DB47148E}.Debug|x64.ActiveCfg = Debug|Any CPU
{83716724-0833-4EB4-BD13-7570DB47148E}.Debug|x64.Build.0 = Debug|Any CPU
{83716724-0833-4EB4-BD13-7570DB47148E}.Debug|x86.ActiveCfg = Debug|Any CPU
{83716724-0833-4EB4-BD13-7570DB47148E}.Debug|x86.Build.0 = Debug|Any CPU
{83716724-0833-4EB4-BD13-7570DB47148E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{83716724-0833-4EB4-BD13-7570DB47148E}.Release|Any CPU.Build.0 = Release|Any CPU
{83716724-0833-4EB4-BD13-7570DB47148E}.Release|x64.ActiveCfg = Release|Any CPU
{83716724-0833-4EB4-BD13-7570DB47148E}.Release|x64.Build.0 = Release|Any CPU
{83716724-0833-4EB4-BD13-7570DB47148E}.Release|x86.ActiveCfg = Release|Any CPU
{83716724-0833-4EB4-BD13-7570DB47148E}.Release|x86.Build.0 = Release|Any CPU
{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}.Debug|x64.ActiveCfg = Debug|Any CPU
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Debug|x64.Build.0 = Debug|Any CPU
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Debug|x86.ActiveCfg = Debug|Any CPU
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Debug|x86.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
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Release|x64.ActiveCfg = Release|Any CPU
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Release|x64.Build.0 = Release|Any CPU
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Release|x86.ActiveCfg = Release|Any CPU
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Release|x86.Build.0 = Release|Any CPU
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Debug|x64.ActiveCfg = Debug|Any CPU
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Debug|x64.Build.0 = Debug|Any CPU
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Debug|x86.ActiveCfg = Debug|Any CPU
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Debug|x86.Build.0 = Debug|Any CPU
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Release|Any CPU.Build.0 = Release|Any CPU
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Release|x64.ActiveCfg = Release|Any CPU
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Release|x64.Build.0 = Release|Any CPU
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Release|x86.ActiveCfg = Release|Any CPU
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Release|x86.Build.0 = Release|Any CPU
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Debug|x64.ActiveCfg = Debug|Any CPU
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Debug|x64.Build.0 = Debug|Any CPU
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Debug|x86.ActiveCfg = Debug|Any CPU
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Debug|x86.Build.0 = Debug|Any CPU
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Release|Any CPU.Build.0 = Release|Any CPU
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Release|x64.ActiveCfg = Release|Any CPU
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Release|x64.Build.0 = Release|Any CPU
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Release|x86.ActiveCfg = Release|Any CPU
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Release|x86.Build.0 = Release|Any CPU
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Debug|x64.ActiveCfg = Debug|Any CPU
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Debug|x64.Build.0 = Debug|Any CPU
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Debug|x86.ActiveCfg = Debug|Any CPU
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Debug|x86.Build.0 = Debug|Any CPU
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Release|Any CPU.Build.0 = Release|Any CPU
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Release|x64.ActiveCfg = Release|Any CPU
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Release|x64.Build.0 = Release|Any CPU
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Release|x86.ActiveCfg = Release|Any CPU
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Release|x86.Build.0 = Release|Any CPU
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Debug|Any CPU.Build.0 = Debug|Any CPU
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Debug|x64.ActiveCfg = Debug|Any CPU
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Debug|x64.Build.0 = Debug|Any CPU
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Debug|x86.ActiveCfg = Debug|Any CPU
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Debug|x86.Build.0 = Debug|Any CPU
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Release|Any CPU.ActiveCfg = Release|Any CPU
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Release|Any CPU.Build.0 = Release|Any CPU
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Release|x64.ActiveCfg = Release|Any CPU
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Release|x64.Build.0 = Release|Any CPU
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Release|x86.ActiveCfg = Release|Any CPU
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Release|x86.Build.0 = Release|Any CPU
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Debug|x64.ActiveCfg = Debug|Any CPU
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Debug|x64.Build.0 = Debug|Any CPU
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Debug|x86.ActiveCfg = Debug|Any CPU
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Debug|x86.Build.0 = Debug|Any CPU
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Release|Any CPU.Build.0 = Release|Any CPU
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Release|x64.ActiveCfg = Release|Any CPU
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Release|x64.Build.0 = Release|Any CPU
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Release|x86.ActiveCfg = Release|Any CPU
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Release|x86.Build.0 = Release|Any CPU
{45287280-FC03-4233-9012-193F4CE41964}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{45287280-FC03-4233-9012-193F4CE41964}.Debug|Any CPU.Build.0 = Debug|Any CPU
{45287280-FC03-4233-9012-193F4CE41964}.Debug|x64.ActiveCfg = Debug|Any CPU
{45287280-FC03-4233-9012-193F4CE41964}.Debug|x64.Build.0 = Debug|Any CPU
{45287280-FC03-4233-9012-193F4CE41964}.Debug|x86.ActiveCfg = Debug|Any CPU
{45287280-FC03-4233-9012-193F4CE41964}.Debug|x86.Build.0 = Debug|Any CPU
{45287280-FC03-4233-9012-193F4CE41964}.Release|Any CPU.ActiveCfg = Release|Any CPU
{45287280-FC03-4233-9012-193F4CE41964}.Release|Any CPU.Build.0 = Release|Any CPU
{45287280-FC03-4233-9012-193F4CE41964}.Release|x64.ActiveCfg = Release|Any CPU
{45287280-FC03-4233-9012-193F4CE41964}.Release|x64.Build.0 = Release|Any CPU
{45287280-FC03-4233-9012-193F4CE41964}.Release|x86.ActiveCfg = Release|Any CPU
{45287280-FC03-4233-9012-193F4CE41964}.Release|x86.Build.0 = Release|Any CPU
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Debug|x64.ActiveCfg = Debug|Any CPU
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Debug|x64.Build.0 = Debug|Any CPU
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Debug|x86.ActiveCfg = Debug|Any CPU
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Debug|x86.Build.0 = Debug|Any CPU
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Release|Any CPU.Build.0 = Release|Any CPU
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Release|x64.ActiveCfg = Release|Any CPU
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Release|x64.Build.0 = Release|Any CPU
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Release|x86.ActiveCfg = Release|Any CPU
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Release|x86.Build.0 = Release|Any CPU
{6B83C5F2-EA81-4723-87EB-99101697B232}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6B83C5F2-EA81-4723-87EB-99101697B232}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6B83C5F2-EA81-4723-87EB-99101697B232}.Debug|x64.ActiveCfg = Debug|Any CPU
{6B83C5F2-EA81-4723-87EB-99101697B232}.Debug|x64.Build.0 = Debug|Any CPU
{6B83C5F2-EA81-4723-87EB-99101697B232}.Debug|x86.ActiveCfg = Debug|Any CPU
{6B83C5F2-EA81-4723-87EB-99101697B232}.Debug|x86.Build.0 = Debug|Any CPU
{6B83C5F2-EA81-4723-87EB-99101697B232}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6B83C5F2-EA81-4723-87EB-99101697B232}.Release|Any CPU.Build.0 = Release|Any CPU
{6B83C5F2-EA81-4723-87EB-99101697B232}.Release|x64.ActiveCfg = Release|Any CPU
{6B83C5F2-EA81-4723-87EB-99101697B232}.Release|x64.Build.0 = Release|Any CPU
{6B83C5F2-EA81-4723-87EB-99101697B232}.Release|x86.ActiveCfg = Release|Any CPU
{6B83C5F2-EA81-4723-87EB-99101697B232}.Release|x86.Build.0 = Release|Any CPU
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Debug|x64.ActiveCfg = Debug|Any CPU
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Debug|x64.Build.0 = Debug|Any CPU
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Debug|x86.ActiveCfg = Debug|Any CPU
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Debug|x86.Build.0 = Debug|Any CPU
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Release|Any CPU.Build.0 = Release|Any CPU
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Release|x64.ActiveCfg = Release|Any CPU
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Release|x64.Build.0 = Release|Any CPU
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Release|x86.ActiveCfg = Release|Any CPU
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Release|x86.Build.0 = Release|Any CPU
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Debug|Any CPU.Build.0 = Debug|Any CPU
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Debug|x64.ActiveCfg = Debug|Any CPU
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Debug|x64.Build.0 = Debug|Any CPU
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Debug|x86.ActiveCfg = Debug|Any CPU
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Debug|x86.Build.0 = Debug|Any CPU
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Release|Any CPU.ActiveCfg = Release|Any CPU
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Release|Any CPU.Build.0 = Release|Any CPU
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Release|x64.ActiveCfg = Release|Any CPU
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Release|x64.Build.0 = Release|Any CPU
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Release|x86.ActiveCfg = Release|Any CPU
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Release|x86.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}.Debug|x64.ActiveCfg = Debug|Any CPU
{D064D5C1-3311-470C-92A1-41E913125C14}.Debug|x64.Build.0 = Debug|Any CPU
{D064D5C1-3311-470C-92A1-41E913125C14}.Debug|x86.ActiveCfg = Debug|Any CPU
{D064D5C1-3311-470C-92A1-41E913125C14}.Debug|x86.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
{D064D5C1-3311-470C-92A1-41E913125C14}.Release|x64.ActiveCfg = Release|Any CPU
{D064D5C1-3311-470C-92A1-41E913125C14}.Release|x64.Build.0 = Release|Any CPU
{D064D5C1-3311-470C-92A1-41E913125C14}.Release|x86.ActiveCfg = Release|Any CPU
{D064D5C1-3311-470C-92A1-41E913125C14}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{478DF014-BF69-41BA-B78A-AAC0918337D8} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{D064D5C1-3311-470C-92A1-41E913125C14} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,12 @@
# StellaOps.Policy — Agent Charter
## Mission
Deliver the policy engine outlined in `docs/ARCHITECTURE_SCANNER.md` and related prose:
- Define YAML schema (ignore rules, VEX inclusion/exclusion, vendor precedence, license gates).
- Provide policy snapshot storage with revision digests and diagnostics.
- Offer preview APIs to compare policy impacts on existing reports.
## Expectations
- Coordinate with Scanner.WebService, Feedser, Vexer, UI, Notify.
- Maintain deterministic serialization and unit tests for precedence rules.
- Update `TASKS.md` and broadcast contract changes.

View File

@@ -0,0 +1,12 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public interface IPolicyAuditRepository
{
Task AddAsync(PolicyAuditEntry entry, CancellationToken cancellationToken = default);
Task<IReadOnlyList<PolicyAuditEntry>> ListAsync(int limit, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public sealed class InMemoryPolicyAuditRepository : IPolicyAuditRepository
{
private readonly List<PolicyAuditEntry> _entries = new();
private readonly SemaphoreSlim _mutex = new(1, 1);
public async Task AddAsync(PolicyAuditEntry entry, CancellationToken cancellationToken = default)
{
if (entry is null)
{
throw new ArgumentNullException(nameof(entry));
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_entries.Add(entry);
_entries.Sort(static (left, right) => left.CreatedAt.CompareTo(right.CreatedAt));
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<PolicyAuditEntry>> ListAsync(int limit, CancellationToken cancellationToken = default)
{
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
IEnumerable<PolicyAuditEntry> query = _entries;
if (limit > 0)
{
query = query.TakeLast(limit);
}
return query.ToImmutableArray();
}
finally
{
_mutex.Release();
}
}
}

View File

@@ -0,0 +1,12 @@
using System;
namespace StellaOps.Policy;
public sealed record PolicyAuditEntry(
Guid Id,
DateTimeOffset CreatedAt,
string Action,
string RevisionId,
string Digest,
string? Actor,
string Message);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
using System;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Policy;
public sealed record PolicyDiagnosticsReport(
string Version,
int RuleCount,
int ErrorCount,
int WarningCount,
DateTimeOffset GeneratedAt,
ImmutableArray<PolicyIssue> Issues,
ImmutableArray<string> Recommendations);
public static class PolicyDiagnostics
{
public static PolicyDiagnosticsReport Create(PolicyBindingResult bindingResult, TimeProvider? timeProvider = null)
{
if (bindingResult is null)
{
throw new ArgumentNullException(nameof(bindingResult));
}
var time = (timeProvider ?? TimeProvider.System).GetUtcNow();
var errorCount = bindingResult.Issues.Count(static issue => issue.Severity == PolicyIssueSeverity.Error);
var warningCount = bindingResult.Issues.Count(static issue => issue.Severity == PolicyIssueSeverity.Warning);
var recommendations = BuildRecommendations(bindingResult.Document, errorCount, warningCount);
return new PolicyDiagnosticsReport(
bindingResult.Document.Version,
bindingResult.Document.Rules.Length,
errorCount,
warningCount,
time,
bindingResult.Issues,
recommendations);
}
private static ImmutableArray<string> BuildRecommendations(PolicyDocument document, int errorCount, int warningCount)
{
var messages = ImmutableArray.CreateBuilder<string>();
if (errorCount > 0)
{
messages.Add("Resolve policy errors before promoting the revision; fallback rules may be applied while errors remain.");
}
if (warningCount > 0)
{
messages.Add("Review policy warnings and ensure intentional overrides are documented.");
}
if (document.Rules.Length == 0)
{
messages.Add("Add at least one policy rule to enforce gating logic.");
}
var quietRules = document.Rules
.Where(static rule => rule.Action.Quiet)
.Select(static rule => rule.Name)
.ToArray();
if (quietRules.Length > 0)
{
messages.Add($"Quiet rules detected ({string.Join(", ", quietRules)}); verify scoring behaviour aligns with expectations.");
}
if (messages.Count == 0)
{
messages.Add("Policy validated successfully; no additional action required.");
}
return messages.ToImmutable();
}
}

View File

@@ -0,0 +1,304 @@
using System;
using System.Buffers;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
namespace StellaOps.Policy;
public static class PolicyDigest
{
public static string Compute(PolicyDocument document)
{
if (document is null)
{
throw new ArgumentNullException(nameof(document));
}
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
{
SkipValidation = true,
}))
{
WriteDocument(writer, document);
}
var hash = SHA256.HashData(buffer.WrittenSpan);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static void WriteDocument(Utf8JsonWriter writer, PolicyDocument document)
{
writer.WriteStartObject();
writer.WriteString("version", document.Version);
if (!document.Metadata.IsEmpty)
{
writer.WritePropertyName("metadata");
writer.WriteStartObject();
foreach (var pair in document.Metadata.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal))
{
writer.WriteString(pair.Key, pair.Value);
}
writer.WriteEndObject();
}
writer.WritePropertyName("rules");
writer.WriteStartArray();
foreach (var rule in document.Rules)
{
WriteRule(writer, rule);
}
writer.WriteEndArray();
if (!document.Exceptions.Effects.IsDefaultOrEmpty || !document.Exceptions.RoutingTemplates.IsDefaultOrEmpty)
{
writer.WritePropertyName("exceptions");
writer.WriteStartObject();
if (!document.Exceptions.Effects.IsDefaultOrEmpty)
{
writer.WritePropertyName("effects");
writer.WriteStartArray();
foreach (var effect in document.Exceptions.Effects
.OrderBy(static e => e.Id, StringComparer.Ordinal))
{
WriteExceptionEffect(writer, effect);
}
writer.WriteEndArray();
}
if (!document.Exceptions.RoutingTemplates.IsDefaultOrEmpty)
{
writer.WritePropertyName("routingTemplates");
writer.WriteStartArray();
foreach (var template in document.Exceptions.RoutingTemplates
.OrderBy(static t => t.Id, StringComparer.Ordinal))
{
WriteExceptionRoutingTemplate(writer, template);
}
writer.WriteEndArray();
}
writer.WriteEndObject();
}
writer.WriteEndObject();
writer.Flush();
}
private static void WriteRule(Utf8JsonWriter writer, PolicyRule rule)
{
writer.WriteStartObject();
writer.WriteString("name", rule.Name);
if (!string.IsNullOrWhiteSpace(rule.Identifier))
{
writer.WriteString("id", rule.Identifier);
}
if (!string.IsNullOrWhiteSpace(rule.Description))
{
writer.WriteString("description", rule.Description);
}
WriteMetadata(writer, rule.Metadata);
WriteSeverities(writer, rule.Severities);
WriteStringArray(writer, "environments", rule.Environments);
WriteStringArray(writer, "sources", rule.Sources);
WriteStringArray(writer, "vendors", rule.Vendors);
WriteStringArray(writer, "licenses", rule.Licenses);
WriteStringArray(writer, "tags", rule.Tags);
if (!rule.Match.IsEmpty)
{
writer.WritePropertyName("match");
writer.WriteStartObject();
WriteStringArray(writer, "images", rule.Match.Images);
WriteStringArray(writer, "repositories", rule.Match.Repositories);
WriteStringArray(writer, "packages", rule.Match.Packages);
WriteStringArray(writer, "purls", rule.Match.Purls);
WriteStringArray(writer, "cves", rule.Match.Cves);
WriteStringArray(writer, "paths", rule.Match.Paths);
WriteStringArray(writer, "layerDigests", rule.Match.LayerDigests);
WriteStringArray(writer, "usedByEntrypoint", rule.Match.UsedByEntrypoint);
writer.WriteEndObject();
}
WriteAction(writer, rule.Action);
if (rule.Expires is DateTimeOffset expires)
{
writer.WriteString("expires", expires.ToUniversalTime().ToString("O"));
}
if (!string.IsNullOrWhiteSpace(rule.Justification))
{
writer.WriteString("justification", rule.Justification);
}
writer.WriteEndObject();
}
private static void WriteAction(Utf8JsonWriter writer, PolicyAction action)
{
writer.WritePropertyName("action");
writer.WriteStartObject();
writer.WriteString("type", action.Type.ToString().ToLowerInvariant());
if (action.Quiet)
{
writer.WriteBoolean("quiet", true);
}
if (action.Ignore is { } ignore)
{
if (ignore.Until is DateTimeOffset until)
{
writer.WriteString("until", until.ToUniversalTime().ToString("O"));
}
if (!string.IsNullOrWhiteSpace(ignore.Justification))
{
writer.WriteString("justification", ignore.Justification);
}
}
if (action.Escalate is { } escalate)
{
if (escalate.MinimumSeverity is { } severity)
{
writer.WriteString("severity", severity.ToString());
}
if (escalate.RequireKev)
{
writer.WriteBoolean("kev", true);
}
if (escalate.MinimumEpss is double epss)
{
writer.WriteNumber("epss", epss);
}
}
if (action.RequireVex is { } requireVex)
{
WriteStringArray(writer, "vendors", requireVex.Vendors);
WriteStringArray(writer, "justifications", requireVex.Justifications);
}
writer.WriteEndObject();
}
private static void WriteMetadata(Utf8JsonWriter writer, ImmutableDictionary<string, string> metadata)
{
if (metadata.IsEmpty)
{
return;
}
writer.WritePropertyName("metadata");
writer.WriteStartObject();
foreach (var pair in metadata.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal))
{
writer.WriteString(pair.Key, pair.Value);
}
writer.WriteEndObject();
}
private static void WriteSeverities(Utf8JsonWriter writer, ImmutableArray<PolicySeverity> severities)
{
if (severities.IsDefaultOrEmpty)
{
return;
}
writer.WritePropertyName("severity");
writer.WriteStartArray();
foreach (var severity in severities)
{
writer.WriteStringValue(severity.ToString());
}
writer.WriteEndArray();
}
private static void WriteStringArray(Utf8JsonWriter writer, string propertyName, ImmutableArray<string> values)
{
if (values.IsDefaultOrEmpty)
{
return;
}
writer.WritePropertyName(propertyName);
writer.WriteStartArray();
foreach (var value in values)
{
writer.WriteStringValue(value);
}
writer.WriteEndArray();
}
private static void WriteExceptionEffect(Utf8JsonWriter writer, PolicyExceptionEffect effect)
{
writer.WriteStartObject();
writer.WriteString("id", effect.Id);
if (!string.IsNullOrWhiteSpace(effect.Name))
{
writer.WriteString("name", effect.Name);
}
writer.WriteString("effect", effect.Effect.ToString().ToLowerInvariant());
if (effect.DowngradeSeverity is { } downgradeSeverity)
{
writer.WriteString("downgradeSeverity", downgradeSeverity.ToString());
}
if (!string.IsNullOrWhiteSpace(effect.RequiredControlId))
{
writer.WriteString("requiredControlId", effect.RequiredControlId);
}
if (!string.IsNullOrWhiteSpace(effect.RoutingTemplate))
{
writer.WriteString("routingTemplate", effect.RoutingTemplate);
}
if (effect.MaxDurationDays is int maxDurationDays)
{
writer.WriteNumber("maxDurationDays", maxDurationDays);
}
if (!string.IsNullOrWhiteSpace(effect.Description))
{
writer.WriteString("description", effect.Description);
}
writer.WriteEndObject();
}
private static void WriteExceptionRoutingTemplate(Utf8JsonWriter writer, PolicyExceptionRoutingTemplate template)
{
writer.WriteStartObject();
writer.WriteString("id", template.Id);
writer.WriteString("authorityRouteId", template.AuthorityRouteId);
if (template.RequireMfa)
{
writer.WriteBoolean("requireMfa", true);
}
if (!string.IsNullOrWhiteSpace(template.Description))
{
writer.WriteString("description", template.Description);
}
writer.WriteEndObject();
}
}

View File

@@ -0,0 +1,241 @@
using System;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Policy;
/// <summary>
/// Canonical representation of a StellaOps policy document.
/// </summary>
public sealed record PolicyDocument(
string Version,
ImmutableArray<PolicyRule> Rules,
ImmutableDictionary<string, string> Metadata,
PolicyExceptionConfiguration Exceptions)
{
public static PolicyDocument Empty { get; } = new(
PolicySchema.CurrentVersion,
ImmutableArray<PolicyRule>.Empty,
ImmutableDictionary<string, string>.Empty,
PolicyExceptionConfiguration.Empty);
}
public static class PolicySchema
{
public const string SchemaId = "https://schemas.stella-ops.org/policy/policy-schema@1.json";
public const string CurrentVersion = "1.0";
public static PolicyDocumentFormat DetectFormat(string fileName)
{
if (fileName is null)
{
throw new ArgumentNullException(nameof(fileName));
}
var lower = fileName.Trim().ToLowerInvariant();
if (lower.EndsWith(".yaml", StringComparison.Ordinal)
|| lower.EndsWith(".yml", StringComparison.Ordinal)
|| lower.EndsWith(".stella", StringComparison.Ordinal))
{
return PolicyDocumentFormat.Yaml;
}
return PolicyDocumentFormat.Json;
}
}
public sealed record PolicyRule(
string Name,
string? Identifier,
string? Description,
PolicyAction Action,
ImmutableArray<PolicySeverity> Severities,
ImmutableArray<string> Environments,
ImmutableArray<string> Sources,
ImmutableArray<string> Vendors,
ImmutableArray<string> Licenses,
ImmutableArray<string> Tags,
PolicyRuleMatchCriteria Match,
DateTimeOffset? Expires,
string? Justification,
ImmutableDictionary<string, string> Metadata)
{
public static PolicyRuleMatchCriteria EmptyMatch { get; } = new(
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty);
public static PolicyRule Create(
string name,
PolicyAction action,
ImmutableArray<PolicySeverity> severities,
ImmutableArray<string> environments,
ImmutableArray<string> sources,
ImmutableArray<string> vendors,
ImmutableArray<string> licenses,
ImmutableArray<string> tags,
PolicyRuleMatchCriteria match,
DateTimeOffset? expires,
string? justification,
string? identifier = null,
string? description = null,
ImmutableDictionary<string, string>? metadata = null)
{
metadata ??= ImmutableDictionary<string, string>.Empty;
return new PolicyRule(
name,
identifier,
description,
action,
severities,
environments,
sources,
vendors,
licenses,
tags,
match,
expires,
justification,
metadata);
}
public bool MatchesAnyEnvironment => Environments.IsDefaultOrEmpty;
}
public sealed record PolicyRuleMatchCriteria(
ImmutableArray<string> Images,
ImmutableArray<string> Repositories,
ImmutableArray<string> Packages,
ImmutableArray<string> Purls,
ImmutableArray<string> Cves,
ImmutableArray<string> Paths,
ImmutableArray<string> LayerDigests,
ImmutableArray<string> UsedByEntrypoint)
{
public static PolicyRuleMatchCriteria Create(
ImmutableArray<string> images,
ImmutableArray<string> repositories,
ImmutableArray<string> packages,
ImmutableArray<string> purls,
ImmutableArray<string> cves,
ImmutableArray<string> paths,
ImmutableArray<string> layerDigests,
ImmutableArray<string> usedByEntrypoint)
=> new(
images,
repositories,
packages,
purls,
cves,
paths,
layerDigests,
usedByEntrypoint);
public static PolicyRuleMatchCriteria Empty { get; } = new(
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty);
public bool IsEmpty =>
Images.IsDefaultOrEmpty &&
Repositories.IsDefaultOrEmpty &&
Packages.IsDefaultOrEmpty &&
Purls.IsDefaultOrEmpty &&
Cves.IsDefaultOrEmpty &&
Paths.IsDefaultOrEmpty &&
LayerDigests.IsDefaultOrEmpty &&
UsedByEntrypoint.IsDefaultOrEmpty;
}
public sealed record PolicyAction(
PolicyActionType Type,
PolicyIgnoreOptions? Ignore,
PolicyEscalateOptions? Escalate,
PolicyRequireVexOptions? RequireVex,
bool Quiet);
public enum PolicyActionType
{
Block,
Ignore,
Warn,
Defer,
Escalate,
RequireVex,
}
public sealed record PolicyIgnoreOptions(DateTimeOffset? Until, string? Justification);
public sealed record PolicyEscalateOptions(
PolicySeverity? MinimumSeverity,
bool RequireKev,
double? MinimumEpss);
public sealed record PolicyRequireVexOptions(
ImmutableArray<string> Vendors,
ImmutableArray<string> Justifications);
public enum PolicySeverity
{
Critical,
High,
Medium,
Low,
Informational,
None,
Unknown,
}
public sealed record PolicyExceptionConfiguration(
ImmutableArray<PolicyExceptionEffect> Effects,
ImmutableArray<PolicyExceptionRoutingTemplate> RoutingTemplates)
{
public static PolicyExceptionConfiguration Empty { get; } = new(
ImmutableArray<PolicyExceptionEffect>.Empty,
ImmutableArray<PolicyExceptionRoutingTemplate>.Empty);
public PolicyExceptionEffect? FindEffect(string effectId)
{
if (string.IsNullOrWhiteSpace(effectId) || Effects.IsDefaultOrEmpty)
{
return null;
}
return Effects.FirstOrDefault(effect =>
string.Equals(effect.Id, effectId, StringComparison.OrdinalIgnoreCase));
}
}
public sealed record PolicyExceptionEffect(
string Id,
string? Name,
PolicyExceptionEffectType Effect,
PolicySeverity? DowngradeSeverity,
string? RequiredControlId,
string? RoutingTemplate,
int? MaxDurationDays,
string? Description);
public enum PolicyExceptionEffectType
{
Suppress,
Defer,
Downgrade,
RequireControl,
}
public sealed record PolicyExceptionRoutingTemplate(
string Id,
string AuthorityRouteId,
bool RequireMfa,
string? Description);

View File

@@ -0,0 +1,552 @@
using System;
using System.Collections.Immutable;
using System.Globalization;
namespace StellaOps.Policy;
public static class PolicyEvaluation
{
public static PolicyVerdict EvaluateFinding(PolicyDocument document, PolicyScoringConfig scoringConfig, PolicyFinding finding)
{
if (document is null)
{
throw new ArgumentNullException(nameof(document));
}
if (scoringConfig is null)
{
throw new ArgumentNullException(nameof(scoringConfig));
}
if (finding is null)
{
throw new ArgumentNullException(nameof(finding));
}
var severityWeight = scoringConfig.SeverityWeights.TryGetValue(finding.Severity, out var weight)
? weight
: scoringConfig.SeverityWeights.GetValueOrDefault(PolicySeverity.Unknown, 0);
var trustKey = ResolveTrustKey(finding);
var trustWeight = ResolveTrustWeight(scoringConfig, trustKey);
var reachabilityKey = ResolveReachabilityKey(finding);
var reachabilityWeight = ResolveReachabilityWeight(scoringConfig, reachabilityKey, out var resolvedReachabilityKey);
var baseScore = severityWeight * trustWeight * reachabilityWeight;
var components = new ScoringComponents(
severityWeight,
trustWeight,
reachabilityWeight,
baseScore,
trustKey,
resolvedReachabilityKey);
var unknownConfidence = ComputeUnknownConfidence(scoringConfig.UnknownConfidence, finding);
foreach (var rule in document.Rules)
{
if (!RuleMatches(rule, finding))
{
continue;
}
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)
{
var action = rule.Action;
var status = MapAction(action);
var notes = BuildNotes(action);
var inputs = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
inputs["severityWeight"] = components.SeverityWeight;
inputs["trustWeight"] = components.TrustWeight;
inputs["reachabilityWeight"] = components.ReachabilityWeight;
inputs["baseScore"] = components.BaseScore;
if (!string.IsNullOrWhiteSpace(components.TrustKey))
{
inputs[$"trustWeight.{components.TrustKey}"] = components.TrustWeight;
}
if (!string.IsNullOrWhiteSpace(components.ReachabilityKey))
{
inputs[$"reachability.{components.ReachabilityKey}"] = components.ReachabilityWeight;
}
if (unknownConfidence is { Band.Description: { Length: > 0 } description })
{
notes = AppendNote(notes, description);
}
if (unknownConfidence is { } unknownDetails)
{
inputs["unknownConfidence"] = unknownDetails.Confidence;
inputs["unknownAgeDays"] = unknownDetails.AgeDays;
}
double score = components.BaseScore;
string? quietedBy = null;
var quiet = false;
var quietRequested = action.Quiet;
var quietAllowed = quietRequested && (action.RequireVex is not null || action.Type == PolicyActionType.RequireVex);
if (quietRequested && !quietAllowed)
{
var warnInputs = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in inputs)
{
warnInputs[pair.Key] = pair.Value;
}
if (unknownConfidence is { } unknownInfo)
{
warnInputs["unknownConfidence"] = unknownInfo.Confidence;
warnInputs["unknownAgeDays"] = unknownInfo.AgeDays;
}
var warnPenalty = config.WarnPenalty;
warnInputs["warnPenalty"] = warnPenalty;
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,
action.Type.ToString(),
warnNotes,
warnScore,
config.Version,
warnInputs.ToImmutable(),
QuietedBy: null,
Quiet: false,
UnknownConfidence: unknownConfidence?.Confidence,
ConfidenceBand: unknownConfidence?.Band.Name,
UnknownAgeDays: unknownConfidence?.AgeDays,
SourceTrust: components.TrustKey,
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 (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,
score,
config.Version,
inputs.ToImmutable(),
quietedBy,
quiet,
unknownConfidence?.Confidence,
unknownConfidence?.Band.Name,
unknownConfidence?.AgeDays,
components.TrustKey,
components.ReachabilityKey);
}
private static double ApplyPenalty(double score, double penalty, ImmutableDictionary<string, double>.Builder inputs, string key)
{
if (penalty <= 0)
{
return score;
}
inputs[key] = penalty;
return Math.Max(0, score - penalty);
}
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())
{
inputsBuilder[pair.Key] = pair.Value;
}
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,
};
}
private static UnknownConfidenceResult? ComputeUnknownConfidence(PolicyUnknownConfidenceConfig config, PolicyFinding finding)
{
if (!IsUnknownFinding(finding))
{
return null;
}
var ageDays = ResolveUnknownAgeDays(finding);
var rawConfidence = config.Initial - (ageDays * config.DecayPerDay);
var confidence = config.Clamp(rawConfidence);
var band = config.ResolveBand(confidence);
return new UnknownConfidenceResult(ageDays, confidence, band);
}
private static bool IsUnknownFinding(PolicyFinding finding)
{
if (finding.Severity == PolicySeverity.Unknown)
{
return true;
}
if (!finding.Tags.IsDefaultOrEmpty)
{
foreach (var tag in finding.Tags)
{
if (string.Equals(tag, "state:unknown", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
return false;
}
private static double ResolveUnknownAgeDays(PolicyFinding finding)
{
var ageTag = TryGetTagValue(finding.Tags, "unknown-age-days:");
if (!string.IsNullOrWhiteSpace(ageTag) &&
double.TryParse(ageTag, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedAge) &&
parsedAge >= 0)
{
return parsedAge;
}
var sinceTag = TryGetTagValue(finding.Tags, "unknown-since:");
if (string.IsNullOrWhiteSpace(sinceTag))
{
return 0;
}
if (!DateTimeOffset.TryParse(sinceTag, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var since))
{
return 0;
}
var observedTag = TryGetTagValue(finding.Tags, "observed-at:");
if (!string.IsNullOrWhiteSpace(observedTag) &&
DateTimeOffset.TryParse(observedTag, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var observed) &&
observed > since)
{
return Math.Max(0, (observed - since).TotalDays);
}
return 0;
}
private static string? ResolveTrustKey(PolicyFinding finding)
{
if (!finding.Tags.IsDefaultOrEmpty)
{
var tagged = TryGetTagValue(finding.Tags, "trust:");
if (!string.IsNullOrWhiteSpace(tagged))
{
return tagged;
}
}
if (!string.IsNullOrWhiteSpace(finding.Source))
{
return finding.Source;
}
if (!string.IsNullOrWhiteSpace(finding.Vendor))
{
return finding.Vendor;
}
return null;
}
private static double ResolveTrustWeight(PolicyScoringConfig config, string? key)
{
if (string.IsNullOrWhiteSpace(key) || config.TrustOverrides.IsEmpty)
{
return 1.0;
}
return config.TrustOverrides.TryGetValue(key, out var weight) ? weight : 1.0;
}
private static string? ResolveReachabilityKey(PolicyFinding finding)
{
if (finding.Tags.IsDefaultOrEmpty)
{
return null;
}
var reachability = TryGetTagValue(finding.Tags, "reachability:");
if (!string.IsNullOrWhiteSpace(reachability))
{
return reachability;
}
var usage = TryGetTagValue(finding.Tags, "usage:");
if (!string.IsNullOrWhiteSpace(usage))
{
return usage;
}
return null;
}
private static double ResolveReachabilityWeight(PolicyScoringConfig config, string? key, out string? resolvedKey)
{
if (!string.IsNullOrWhiteSpace(key) && config.ReachabilityBuckets.TryGetValue(key, out var weight))
{
resolvedKey = key;
return weight;
}
if (config.ReachabilityBuckets.TryGetValue("unknown", out var unknownWeight))
{
resolvedKey = "unknown";
return unknownWeight;
}
resolvedKey = key;
return 1.0;
}
private static string? TryGetTagValue(ImmutableArray<string> tags, string prefix)
{
if (tags.IsDefaultOrEmpty)
{
return null;
}
foreach (var tag in tags)
{
if (string.IsNullOrWhiteSpace(tag))
{
continue;
}
if (tag.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
var value = tag[prefix.Length..].Trim();
if (!string.IsNullOrEmpty(value))
{
return value;
}
}
}
return null;
}
private readonly record struct ScoringComponents(
double SeverityWeight,
double TrustWeight,
double ReachabilityWeight,
double BaseScore,
string? TrustKey,
string? ReachabilityKey);
private readonly struct UnknownConfidenceResult
{
public UnknownConfidenceResult(double ageDays, double confidence, PolicyUnknownConfidenceBand band)
{
AgeDays = ageDays;
Confidence = confidence;
Band = band;
}
public double AgeDays { get; }
public double Confidence { get; }
public PolicyUnknownConfidenceBand Band { get; }
}
private static bool RuleMatches(PolicyRule rule, PolicyFinding finding)
{
if (!rule.Severities.IsDefaultOrEmpty && !rule.Severities.Contains(finding.Severity))
{
return false;
}
if (!Matches(rule.Environments, finding.Environment))
{
return false;
}
if (!Matches(rule.Sources, finding.Source))
{
return false;
}
if (!Matches(rule.Vendors, finding.Vendor))
{
return false;
}
if (!Matches(rule.Licenses, finding.License))
{
return false;
}
if (!RuleMatchCriteria(rule.Match, finding))
{
return false;
}
return true;
}
private static bool Matches(ImmutableArray<string> ruleValues, string? candidate)
{
if (ruleValues.IsDefaultOrEmpty)
{
return true;
}
if (string.IsNullOrWhiteSpace(candidate))
{
return false;
}
return ruleValues.Contains(candidate, StringComparer.OrdinalIgnoreCase);
}
private static bool RuleMatchCriteria(PolicyRuleMatchCriteria criteria, PolicyFinding finding)
{
if (!criteria.Images.IsDefaultOrEmpty && !ContainsValue(criteria.Images, finding.Image, StringComparer.OrdinalIgnoreCase))
{
return false;
}
if (!criteria.Repositories.IsDefaultOrEmpty && !ContainsValue(criteria.Repositories, finding.Repository, StringComparer.OrdinalIgnoreCase))
{
return false;
}
if (!criteria.Packages.IsDefaultOrEmpty && !ContainsValue(criteria.Packages, finding.Package, StringComparer.OrdinalIgnoreCase))
{
return false;
}
if (!criteria.Purls.IsDefaultOrEmpty && !ContainsValue(criteria.Purls, finding.Purl, StringComparer.OrdinalIgnoreCase))
{
return false;
}
if (!criteria.Cves.IsDefaultOrEmpty && !ContainsValue(criteria.Cves, finding.Cve, StringComparer.OrdinalIgnoreCase))
{
return false;
}
if (!criteria.Paths.IsDefaultOrEmpty && !ContainsValue(criteria.Paths, finding.Path, StringComparer.Ordinal))
{
return false;
}
if (!criteria.LayerDigests.IsDefaultOrEmpty && !ContainsValue(criteria.LayerDigests, finding.LayerDigest, StringComparer.OrdinalIgnoreCase))
{
return false;
}
if (!criteria.UsedByEntrypoint.IsDefaultOrEmpty)
{
var match = false;
foreach (var tag in criteria.UsedByEntrypoint)
{
if (finding.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase))
{
match = true;
break;
}
}
if (!match)
{
return false;
}
}
return true;
}
private static bool ContainsValue(ImmutableArray<string> values, string? candidate, StringComparer comparer)
{
if (values.IsDefaultOrEmpty)
{
return true;
}
if (string.IsNullOrWhiteSpace(candidate))
{
return false;
}
return values.Contains(candidate, comparer);
}
private static PolicyVerdictStatus MapAction(PolicyAction action)
=> action.Type switch
{
PolicyActionType.Block => PolicyVerdictStatus.Blocked,
PolicyActionType.Ignore => PolicyVerdictStatus.Ignored,
PolicyActionType.Warn => PolicyVerdictStatus.Warned,
PolicyActionType.Defer => PolicyVerdictStatus.Deferred,
PolicyActionType.Escalate => PolicyVerdictStatus.Escalated,
PolicyActionType.RequireVex => PolicyVerdictStatus.RequiresVex,
_ => PolicyVerdictStatus.Pass,
};
private static string? BuildNotes(PolicyAction action)
{
if (action.Ignore is { } ignore && !string.IsNullOrWhiteSpace(ignore.Justification))
{
return ignore.Justification;
}
if (action.Escalate is { } escalate && escalate.MinimumSeverity is { } severity)
{
return $"Escalate >= {severity}";
}
return null;
}
private static string? AppendNote(string? existing, string addition)
=> string.IsNullOrWhiteSpace(existing) ? addition : string.Concat(existing, " | ", addition);
}

View File

@@ -0,0 +1,51 @@
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicyFinding(
string FindingId,
PolicySeverity Severity,
string? Environment,
string? Source,
string? Vendor,
string? License,
string? Image,
string? Repository,
string? Package,
string? Purl,
string? Cve,
string? Path,
string? LayerDigest,
ImmutableArray<string> Tags)
{
public static PolicyFinding Create(
string findingId,
PolicySeverity severity,
string? environment = null,
string? source = null,
string? vendor = null,
string? license = null,
string? image = null,
string? repository = null,
string? package = null,
string? purl = null,
string? cve = null,
string? path = null,
string? layerDigest = null,
ImmutableArray<string>? tags = null)
=> new(
findingId,
severity,
environment,
source,
vendor,
license,
image,
repository,
package,
purl,
cve,
path,
layerDigest,
tags ?? ImmutableArray<string>.Empty);
}

View File

@@ -0,0 +1,28 @@
using System;
namespace StellaOps.Policy;
/// <summary>
/// Represents a validation or normalization issue discovered while processing a policy document.
/// </summary>
public sealed record PolicyIssue(string Code, string Message, PolicyIssueSeverity Severity, string Path)
{
public static PolicyIssue Error(string code, string message, string path)
=> new(code, message, PolicyIssueSeverity.Error, path);
public static PolicyIssue Warning(string code, string message, string path)
=> new(code, message, PolicyIssueSeverity.Warning, path);
public static PolicyIssue Info(string code, string message, string path)
=> new(code, message, PolicyIssueSeverity.Info, path);
public PolicyIssue EnsurePath(string fallbackPath)
=> string.IsNullOrWhiteSpace(Path) ? this with { Path = fallbackPath } : this;
}
public enum PolicyIssueSeverity
{
Error,
Warning,
Info,
}

View File

@@ -0,0 +1,18 @@
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicyPreviewRequest(
string ImageDigest,
ImmutableArray<PolicyFinding> Findings,
ImmutableArray<PolicyVerdict> BaselineVerdicts,
PolicySnapshot? SnapshotOverride = null,
PolicySnapshotContent? ProposedPolicy = null);
public sealed record PolicyPreviewResponse(
bool Success,
string PolicyDigest,
string? RevisionId,
ImmutableArray<PolicyIssue> Issues,
ImmutableArray<PolicyVerdictDiff> Diffs,
int ChangedCount);

View File

@@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy;
public sealed class PolicyPreviewService
{
private readonly PolicySnapshotStore _snapshotStore;
private readonly ILogger<PolicyPreviewService> _logger;
public PolicyPreviewService(PolicySnapshotStore snapshotStore, ILogger<PolicyPreviewService> logger)
{
_snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PolicyPreviewResponse> PreviewAsync(PolicyPreviewRequest request, CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var (snapshot, bindingIssues) = await ResolveSnapshotAsync(request, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
_logger.LogWarning("Policy preview failed: snapshot unavailable or validation errors. Issues={Count}", bindingIssues.Length);
return new PolicyPreviewResponse(false, string.Empty, null, bindingIssues, ImmutableArray<PolicyVerdictDiff>.Empty, 0);
}
var projected = Evaluate(snapshot.Document, snapshot.ScoringConfig, request.Findings);
var baseline = BuildBaseline(request.BaselineVerdicts, projected, snapshot.ScoringConfig);
var diffs = BuildDiffs(baseline, projected);
var changed = diffs.Count(static diff => diff.Changed);
_logger.LogDebug("Policy preview computed for {ImageDigest}. Changed={Changed}", request.ImageDigest, changed);
return new PolicyPreviewResponse(true, snapshot.Digest, snapshot.RevisionId, bindingIssues, diffs, changed);
}
private async Task<(PolicySnapshot? Snapshot, ImmutableArray<PolicyIssue> Issues)> ResolveSnapshotAsync(PolicyPreviewRequest request, CancellationToken cancellationToken)
{
if (request.ProposedPolicy is not null)
{
var binding = PolicyBinder.Bind(request.ProposedPolicy.Content, request.ProposedPolicy.Format);
if (!binding.Success)
{
return (null, binding.Issues);
}
var digest = PolicyDigest.Compute(binding.Document);
var snapshot = new PolicySnapshot(
request.SnapshotOverride?.RevisionNumber + 1 ?? 0,
request.SnapshotOverride?.RevisionId ?? "preview",
digest,
DateTimeOffset.UtcNow,
request.ProposedPolicy.Actor,
request.ProposedPolicy.Format,
binding.Document,
binding.Issues,
PolicyScoringConfig.Default);
return (snapshot, binding.Issues);
}
if (request.SnapshotOverride is not null)
{
return (request.SnapshotOverride, ImmutableArray<PolicyIssue>.Empty);
}
var latest = await _snapshotStore.GetLatestAsync(cancellationToken).ConfigureAwait(false);
if (latest is not null)
{
return (latest, ImmutableArray<PolicyIssue>.Empty);
}
return (null, ImmutableArray.Create(PolicyIssue.Error("policy.preview.snapshot_missing", "No policy snapshot is available for preview.", "$")));
}
private static ImmutableArray<PolicyVerdict> Evaluate(PolicyDocument document, PolicyScoringConfig scoringConfig, ImmutableArray<PolicyFinding> findings)
{
if (findings.IsDefaultOrEmpty)
{
return ImmutableArray<PolicyVerdict>.Empty;
}
var results = ImmutableArray.CreateBuilder<PolicyVerdict>(findings.Length);
foreach (var finding in findings)
{
var verdict = PolicyEvaluation.EvaluateFinding(document, scoringConfig, finding);
results.Add(verdict);
}
return results.ToImmutable();
}
private static ImmutableDictionary<string, PolicyVerdict> BuildBaseline(ImmutableArray<PolicyVerdict> baseline, ImmutableArray<PolicyVerdict> projected, PolicyScoringConfig scoringConfig)
{
var builder = ImmutableDictionary.CreateBuilder<string, PolicyVerdict>(StringComparer.Ordinal);
if (!baseline.IsDefaultOrEmpty)
{
foreach (var verdict in baseline)
{
if (!string.IsNullOrEmpty(verdict.FindingId) && !builder.ContainsKey(verdict.FindingId))
{
builder.Add(verdict.FindingId, verdict);
}
}
}
foreach (var verdict in projected)
{
if (!builder.ContainsKey(verdict.FindingId))
{
builder.Add(verdict.FindingId, PolicyVerdict.CreateBaseline(verdict.FindingId, scoringConfig));
}
}
return builder.ToImmutable();
}
private static ImmutableArray<PolicyVerdictDiff> BuildDiffs(ImmutableDictionary<string, PolicyVerdict> baseline, ImmutableArray<PolicyVerdict> projected)
{
var diffs = ImmutableArray.CreateBuilder<PolicyVerdictDiff>(projected.Length);
foreach (var verdict in projected.OrderBy(static v => v.FindingId, StringComparer.Ordinal))
{
var baseVerdict = baseline.TryGetValue(verdict.FindingId, out var existing)
? existing
: new PolicyVerdict(verdict.FindingId, PolicyVerdictStatus.Pass);
diffs.Add(new PolicyVerdictDiff(baseVerdict, verdict));
}
return diffs.ToImmutable();
}
}

View File

@@ -0,0 +1,30 @@
using System;
using System.IO;
using System.Reflection;
using System.Text;
namespace StellaOps.Policy;
public static class PolicySchemaResource
{
private const string SchemaResourceName = "StellaOps.Policy.Schemas.policy-schema@1.json";
public static Stream OpenSchemaStream()
{
var assembly = Assembly.GetExecutingAssembly();
var stream = assembly.GetManifestResourceStream(SchemaResourceName);
if (stream is null)
{
throw new InvalidOperationException($"Unable to locate embedded schema resource '{SchemaResourceName}'.");
}
return stream;
}
public static string ReadSchemaJson()
{
using var stream = OpenSchemaStream();
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
return reader.ReadToEnd();
}
}

View File

@@ -0,0 +1,18 @@
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicyScoringConfig(
string Version,
ImmutableDictionary<PolicySeverity, double> SeverityWeights,
double QuietPenalty,
double WarnPenalty,
double IgnorePenalty,
ImmutableDictionary<string, double> TrustOverrides,
ImmutableDictionary<string, double> ReachabilityBuckets,
PolicyUnknownConfidenceConfig UnknownConfidence)
{
public static string BaselineVersion => "1.0";
public static PolicyScoringConfig Default { get; } = PolicyScoringConfigBinder.LoadDefault();
}

View File

@@ -0,0 +1,603 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Json.Schema;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Policy;
public sealed record PolicyScoringBindingResult(
bool Success,
PolicyScoringConfig? Config,
ImmutableArray<PolicyIssue> Issues);
public static class PolicyScoringConfigBinder
{
private const string DefaultResourceName = "StellaOps.Policy.Schemas.policy-scoring-default.json";
private static readonly JsonSchema ScoringSchema = PolicyScoringSchema.Schema;
private static readonly ImmutableDictionary<string, double> DefaultReachabilityBuckets = CreateDefaultReachabilityBuckets();
private static readonly PolicyUnknownConfidenceConfig DefaultUnknownConfidence = CreateDefaultUnknownConfidence();
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
public static PolicyScoringConfig LoadDefault()
{
var assembly = Assembly.GetExecutingAssembly();
using var stream = assembly.GetManifestResourceStream(DefaultResourceName)
?? throw new InvalidOperationException($"Embedded resource '{DefaultResourceName}' not found.");
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
var json = reader.ReadToEnd();
var binding = Bind(json, PolicyDocumentFormat.Json);
if (!binding.Success || binding.Config is null)
{
throw new InvalidOperationException("Failed to load default policy scoring configuration.");
}
return binding.Config;
}
public static PolicyScoringBindingResult Bind(string content, PolicyDocumentFormat format)
{
if (string.IsNullOrWhiteSpace(content))
{
var issue = PolicyIssue.Error("scoring.empty", "Scoring configuration content is empty.", "$");
return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue));
}
try
{
var root = Parse(content, format);
if (root is not JsonObject obj)
{
var issue = PolicyIssue.Error("scoring.invalid", "Scoring configuration must be a JSON object.", "$");
return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue));
}
var issues = ImmutableArray.CreateBuilder<PolicyIssue>();
var schemaIssues = ValidateAgainstSchema(root);
issues.AddRange(schemaIssues);
if (schemaIssues.Any(static issue => issue.Severity == PolicyIssueSeverity.Error))
{
return new PolicyScoringBindingResult(false, null, issues.ToImmutable());
}
var config = BuildConfig(obj, issues);
var hasErrors = issues.Any(issue => issue.Severity == PolicyIssueSeverity.Error);
return new PolicyScoringBindingResult(!hasErrors, config, issues.ToImmutable());
}
catch (JsonException ex)
{
var issue = PolicyIssue.Error("scoring.parse.json", $"Failed to parse scoring JSON: {ex.Message}", "$");
return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue));
}
catch (YamlDotNet.Core.YamlException ex)
{
var issue = PolicyIssue.Error("scoring.parse.yaml", $"Failed to parse scoring YAML: {ex.Message}", "$");
return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue));
}
}
private static JsonNode? Parse(string content, PolicyDocumentFormat format)
{
return format switch
{
PolicyDocumentFormat.Json => JsonNode.Parse(content, new JsonNodeOptions { PropertyNameCaseInsensitive = true }),
PolicyDocumentFormat.Yaml => ConvertYamlToJsonNode(content),
_ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unsupported scoring configuration format."),
};
}
private static JsonNode? ConvertYamlToJsonNode(string content)
{
var yamlObject = YamlDeserializer.Deserialize<object?>(content);
return PolicyBinderUtilities.ConvertYamlObject(yamlObject);
}
private static ImmutableArray<PolicyIssue> ValidateAgainstSchema(JsonNode root)
{
try
{
using var document = JsonDocument.Parse(root.ToJsonString(new JsonSerializerOptions
{
WriteIndented = false,
}));
var result = ScoringSchema.Evaluate(document.RootElement, new EvaluationOptions
{
OutputFormat = OutputFormat.List,
RequireFormatValidation = true,
});
if (result.IsValid)
{
return ImmutableArray<PolicyIssue>.Empty;
}
var issues = ImmutableArray.CreateBuilder<PolicyIssue>();
var seen = new HashSet<string>(StringComparer.Ordinal);
CollectSchemaIssues(result, issues, seen);
return issues.ToImmutable();
}
catch (JsonException ex)
{
return ImmutableArray.Create(PolicyIssue.Error("scoring.schema.normalize", $"Failed to normalize scoring configuration for schema validation: {ex.Message}", "$"));
}
}
private static void CollectSchemaIssues(EvaluationResults result, ImmutableArray<PolicyIssue>.Builder issues, HashSet<string> seen)
{
if (result.Errors is { Count: > 0 })
{
foreach (var pair in result.Errors)
{
var keyword = SanitizeKeyword(pair.Key);
var path = ConvertPointerToPath(result.InstanceLocation?.ToString() ?? "#");
var message = pair.Value ?? "Schema violation.";
var key = $"{path}|{keyword}|{message}";
if (seen.Add(key))
{
issues.Add(PolicyIssue.Error($"scoring.schema.{keyword}", message, path));
}
}
}
if (result.Details is null)
{
return;
}
foreach (var detail in result.Details)
{
CollectSchemaIssues(detail, issues, seen);
}
}
private static string ConvertPointerToPath(string pointer)
{
if (string.IsNullOrEmpty(pointer) || pointer == "#")
{
return "$";
}
if (pointer[0] == '#')
{
pointer = pointer.Length > 1 ? pointer[1..] : string.Empty;
}
if (string.IsNullOrEmpty(pointer))
{
return "$";
}
var segments = pointer.Split('/', StringSplitOptions.RemoveEmptyEntries);
var builder = new StringBuilder("$");
foreach (var segment in segments)
{
var unescaped = segment.Replace("~1", "/").Replace("~0", "~");
if (int.TryParse(unescaped, out var index))
{
builder.Append('[').Append(index).Append(']');
}
else
{
builder.Append('.').Append(unescaped);
}
}
return builder.ToString();
}
private static string SanitizeKeyword(string keyword)
{
if (string.IsNullOrWhiteSpace(keyword))
{
return "unknown";
}
var builder = new StringBuilder(keyword.Length);
foreach (var ch in keyword)
{
if (char.IsLetterOrDigit(ch))
{
builder.Append(char.ToLowerInvariant(ch));
}
else if (ch is '.' or '_' or '-')
{
builder.Append(ch);
}
else
{
builder.Append('_');
}
}
return builder.Length == 0 ? "unknown" : builder.ToString();
}
private static PolicyScoringConfig BuildConfig(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
{
var version = ReadString(obj, "version", issues, required: true) ?? PolicyScoringConfig.BaselineVersion;
var severityWeights = ReadSeverityWeights(obj, issues);
var quietPenalty = ReadDouble(obj, "quietPenalty", issues, defaultValue: 45);
var warnPenalty = ReadDouble(obj, "warnPenalty", issues, defaultValue: 15);
var ignorePenalty = ReadDouble(obj, "ignorePenalty", issues, defaultValue: 35);
var trustOverrides = ReadTrustOverrides(obj, issues);
var reachabilityBuckets = ReadReachabilityBuckets(obj, issues);
var unknownConfidence = ReadUnknownConfidence(obj, issues);
return new PolicyScoringConfig(
version,
severityWeights,
quietPenalty,
warnPenalty,
ignorePenalty,
trustOverrides,
reachabilityBuckets,
unknownConfidence);
}
private static ImmutableDictionary<string, double> CreateDefaultReachabilityBuckets()
{
var builder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
builder["entrypoint"] = 1.0;
builder["direct"] = 0.85;
builder["indirect"] = 0.6;
builder["runtime"] = 0.45;
builder["unreachable"] = 0.25;
builder["unknown"] = 0.5;
return builder.ToImmutable();
}
private static PolicyUnknownConfidenceConfig CreateDefaultUnknownConfidence()
{
var bands = ImmutableArray.Create(
new PolicyUnknownConfidenceBand("high", 0.65, "Fresh unknowns with recent telemetry."),
new PolicyUnknownConfidenceBand("medium", 0.35, "Unknowns aging toward action required."),
new PolicyUnknownConfidenceBand("low", 0.0, "Stale unknowns that must be triaged."));
return new PolicyUnknownConfidenceConfig(0.8, 0.05, 0.2, bands);
}
private static ImmutableDictionary<string, double> ReadReachabilityBuckets(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
{
if (!obj.TryGetPropertyValue("reachabilityBuckets", out var node))
{
issues.Add(PolicyIssue.Warning("scoring.reachability.default", "reachabilityBuckets not specified; defaulting to baseline weights.", "$.reachabilityBuckets"));
return DefaultReachabilityBuckets;
}
if (node is not JsonObject bucketsObj)
{
issues.Add(PolicyIssue.Error("scoring.reachability.type", "reachabilityBuckets must be an object.", "$.reachabilityBuckets"));
return DefaultReachabilityBuckets;
}
var builder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in bucketsObj)
{
if (pair.Value is null)
{
issues.Add(PolicyIssue.Warning("scoring.reachability.null", $"Bucket '{pair.Key}' is null; defaulting to 0.", $"$.reachabilityBuckets.{pair.Key}"));
builder[pair.Key] = 0;
continue;
}
var value = ExtractDouble(pair.Value, issues, $"$.reachabilityBuckets.{pair.Key}");
builder[pair.Key] = value;
}
if (builder.Count == 0)
{
issues.Add(PolicyIssue.Warning("scoring.reachability.empty", "No reachability buckets defined; using defaults.", "$.reachabilityBuckets"));
return DefaultReachabilityBuckets;
}
return builder.ToImmutable();
}
private static PolicyUnknownConfidenceConfig ReadUnknownConfidence(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
{
if (!obj.TryGetPropertyValue("unknownConfidence", out var node))
{
issues.Add(PolicyIssue.Warning("scoring.unknown.default", "unknownConfidence not specified; defaulting to baseline decay settings.", "$.unknownConfidence"));
return DefaultUnknownConfidence;
}
if (node is not JsonObject configObj)
{
issues.Add(PolicyIssue.Error("scoring.unknown.type", "unknownConfidence must be an object.", "$.unknownConfidence"));
return DefaultUnknownConfidence;
}
var initial = DefaultUnknownConfidence.Initial;
if (configObj.TryGetPropertyValue("initial", out var initialNode))
{
initial = ExtractDouble(initialNode, issues, "$.unknownConfidence.initial");
}
else
{
issues.Add(PolicyIssue.Warning("scoring.unknown.initial.default", "initial not specified; using baseline value.", "$.unknownConfidence.initial"));
}
var decay = DefaultUnknownConfidence.DecayPerDay;
if (configObj.TryGetPropertyValue("decayPerDay", out var decayNode))
{
decay = ExtractDouble(decayNode, issues, "$.unknownConfidence.decayPerDay");
}
else
{
issues.Add(PolicyIssue.Warning("scoring.unknown.decay.default", "decayPerDay not specified; using baseline value.", "$.unknownConfidence.decayPerDay"));
}
var floor = DefaultUnknownConfidence.Floor;
if (configObj.TryGetPropertyValue("floor", out var floorNode))
{
floor = ExtractDouble(floorNode, issues, "$.unknownConfidence.floor");
}
else
{
issues.Add(PolicyIssue.Warning("scoring.unknown.floor.default", "floor not specified; using baseline value.", "$.unknownConfidence.floor"));
}
var bands = ReadConfidenceBands(configObj, issues);
if (bands.IsDefaultOrEmpty)
{
bands = DefaultUnknownConfidence.Bands;
}
if (initial < 0 || initial > 1)
{
issues.Add(PolicyIssue.Warning("scoring.unknown.initial.range", "initial confidence should be between 0 and 1. Clamping to valid range.", "$.unknownConfidence.initial"));
initial = Math.Clamp(initial, 0, 1);
}
if (decay < 0 || decay > 1)
{
issues.Add(PolicyIssue.Warning("scoring.unknown.decay.range", "decayPerDay should be between 0 and 1. Clamping to valid range.", "$.unknownConfidence.decayPerDay"));
decay = Math.Clamp(decay, 0, 1);
}
if (floor < 0 || floor > 1)
{
issues.Add(PolicyIssue.Warning("scoring.unknown.floor.range", "floor should be between 0 and 1. Clamping to valid range.", "$.unknownConfidence.floor"));
floor = Math.Clamp(floor, 0, 1);
}
return new PolicyUnknownConfidenceConfig(initial, decay, floor, bands);
}
private static ImmutableArray<PolicyUnknownConfidenceBand> ReadConfidenceBands(JsonObject configObj, ImmutableArray<PolicyIssue>.Builder issues)
{
if (!configObj.TryGetPropertyValue("bands", out var node))
{
return ImmutableArray<PolicyUnknownConfidenceBand>.Empty;
}
if (node is not JsonArray array)
{
issues.Add(PolicyIssue.Error("scoring.unknown.bands.type", "unknownConfidence.bands must be an array.", "$.unknownConfidence.bands"));
return ImmutableArray<PolicyUnknownConfidenceBand>.Empty;
}
var builder = ImmutableArray.CreateBuilder<PolicyUnknownConfidenceBand>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < array.Count; index++)
{
var element = array[index];
if (element is not JsonObject bandObj)
{
issues.Add(PolicyIssue.Warning("scoring.unknown.band.type", "Band entry must be an object.", $"$.unknownConfidence.bands[{index}]"));
continue;
}
string? name = null;
if (bandObj.TryGetPropertyValue("name", out var nameNode) && nameNode is JsonValue nameValue && nameValue.TryGetValue(out string? text))
{
name = text?.Trim();
}
if (string.IsNullOrWhiteSpace(name))
{
issues.Add(PolicyIssue.Error("scoring.unknown.band.name", "Band entry requires a non-empty 'name'.", $"$.unknownConfidence.bands[{index}].name"));
continue;
}
if (!seen.Add(name))
{
issues.Add(PolicyIssue.Warning("scoring.unknown.band.duplicate", $"Duplicate band '{name}' encountered.", $"$.unknownConfidence.bands[{index}].name"));
continue;
}
if (!bandObj.TryGetPropertyValue("min", out var minNode))
{
issues.Add(PolicyIssue.Error("scoring.unknown.band.min", $"Band '{name}' is missing 'min'.", $"$.unknownConfidence.bands[{index}].min"));
continue;
}
var min = ExtractDouble(minNode, issues, $"$.unknownConfidence.bands[{index}].min");
if (min < 0 || min > 1)
{
issues.Add(PolicyIssue.Warning("scoring.unknown.band.range", $"Band '{name}' min should be between 0 and 1. Clamping to valid range.", $"$.unknownConfidence.bands[{index}].min"));
min = Math.Clamp(min, 0, 1);
}
string? description = null;
if (bandObj.TryGetPropertyValue("description", out var descriptionNode) && descriptionNode is JsonValue descriptionValue && descriptionValue.TryGetValue(out string? descriptionText))
{
description = descriptionText?.Trim();
}
builder.Add(new PolicyUnknownConfidenceBand(name, min, description));
}
if (builder.Count == 0)
{
return ImmutableArray<PolicyUnknownConfidenceBand>.Empty;
}
return builder.ToImmutable()
.OrderByDescending(static band => band.Min)
.ToImmutableArray();
}
private static ImmutableDictionary<PolicySeverity, double> ReadSeverityWeights(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
{
if (!obj.TryGetPropertyValue("severityWeights", out var node) || node is not JsonObject severityObj)
{
issues.Add(PolicyIssue.Error("scoring.severityWeights.missing", "severityWeights section is required.", "$.severityWeights"));
return ImmutableDictionary<PolicySeverity, double>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<PolicySeverity, double>();
foreach (var severity in Enum.GetValues<PolicySeverity>())
{
var key = severity.ToString();
if (!severityObj.TryGetPropertyValue(key, out var valueNode))
{
issues.Add(PolicyIssue.Warning("scoring.severityWeights.default", $"Severity '{key}' not specified; defaulting to 0.", $"$.severityWeights.{key}"));
builder[severity] = 0;
continue;
}
var value = ExtractDouble(valueNode, issues, $"$.severityWeights.{key}");
builder[severity] = value;
}
return builder.ToImmutable();
}
private static double ReadDouble(JsonObject obj, string property, ImmutableArray<PolicyIssue>.Builder issues, double defaultValue)
{
if (!obj.TryGetPropertyValue(property, out var node))
{
issues.Add(PolicyIssue.Warning("scoring.numeric.default", $"{property} not specified; defaulting to {defaultValue:0.##}.", $"$.{property}"));
return defaultValue;
}
return ExtractDouble(node, issues, $"$.{property}");
}
private static double ExtractDouble(JsonNode? node, ImmutableArray<PolicyIssue>.Builder issues, string path)
{
if (node is null)
{
issues.Add(PolicyIssue.Warning("scoring.numeric.null", $"Value at {path} missing; defaulting to 0.", path));
return 0;
}
if (node is JsonValue value)
{
if (value.TryGetValue(out double number))
{
return number;
}
if (value.TryGetValue(out string? text) && double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out number))
{
return number;
}
}
issues.Add(PolicyIssue.Error("scoring.numeric.invalid", $"Value at {path} is not numeric.", path));
return 0;
}
private static ImmutableDictionary<string, double> ReadTrustOverrides(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
{
if (!obj.TryGetPropertyValue("trustOverrides", out var node) || node is not JsonObject trustObj)
{
return ImmutableDictionary<string, double>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in trustObj)
{
var value = ExtractDouble(pair.Value, issues, $"$.trustOverrides.{pair.Key}");
builder[pair.Key] = value;
}
return builder.ToImmutable();
}
private static string? ReadString(JsonObject obj, string property, ImmutableArray<PolicyIssue>.Builder issues, bool required)
{
if (!obj.TryGetPropertyValue(property, out var node) || node is null)
{
if (required)
{
issues.Add(PolicyIssue.Error("scoring.string.missing", $"{property} is required.", $"$.{property}"));
}
return null;
}
if (node is JsonValue value && value.TryGetValue(out string? text))
{
return text?.Trim();
}
issues.Add(PolicyIssue.Error("scoring.string.invalid", $"{property} must be a string.", $"$.{property}"));
return null;
}
}
internal static class PolicyBinderUtilities
{
public static JsonNode? ConvertYamlObject(object? value)
{
switch (value)
{
case null:
return null;
case string s when bool.TryParse(s, out var boolValue):
return JsonValue.Create(boolValue);
case string s:
return JsonValue.Create(s);
case bool b:
return JsonValue.Create(b);
case sbyte or byte or short or ushort or int or uint or long or ulong or float or double or decimal:
return JsonValue.Create(Convert.ToDouble(value, CultureInfo.InvariantCulture));
case IDictionary dictionary:
{
var obj = new JsonObject();
foreach (DictionaryEntry entry in dictionary)
{
if (entry.Key is null)
{
continue;
}
obj[entry.Key.ToString()!] = ConvertYamlObject(entry.Value);
}
return obj;
}
case IEnumerable enumerable:
{
var array = new JsonArray();
foreach (var item in enumerable)
{
array.Add(ConvertYamlObject(item));
}
return array;
}
default:
return JsonValue.Create(value.ToString());
}
}
}

View File

@@ -0,0 +1,100 @@
using System;
using System.Buffers;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
namespace StellaOps.Policy;
public static class PolicyScoringConfigDigest
{
public static string Compute(PolicyScoringConfig config)
{
ArgumentNullException.ThrowIfNull(config);
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
{
SkipValidation = true,
}))
{
WriteConfig(writer, config);
}
var hash = SHA256.HashData(buffer.WrittenSpan);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static void WriteConfig(Utf8JsonWriter writer, PolicyScoringConfig config)
{
writer.WriteStartObject();
writer.WriteString("version", config.Version);
writer.WritePropertyName("severityWeights");
writer.WriteStartObject();
foreach (var severity in Enum.GetValues<PolicySeverity>())
{
var key = severity.ToString();
var value = config.SeverityWeights.TryGetValue(severity, out var weight) ? weight : 0;
writer.WriteNumber(key, value);
}
writer.WriteEndObject();
writer.WriteNumber("quietPenalty", config.QuietPenalty);
writer.WriteNumber("warnPenalty", config.WarnPenalty);
writer.WriteNumber("ignorePenalty", config.IgnorePenalty);
if (!config.TrustOverrides.IsEmpty)
{
writer.WritePropertyName("trustOverrides");
writer.WriteStartObject();
foreach (var pair in config.TrustOverrides.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
{
writer.WriteNumber(pair.Key, pair.Value);
}
writer.WriteEndObject();
}
if (!config.ReachabilityBuckets.IsEmpty)
{
writer.WritePropertyName("reachabilityBuckets");
writer.WriteStartObject();
foreach (var pair in config.ReachabilityBuckets.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
{
writer.WriteNumber(pair.Key, pair.Value);
}
writer.WriteEndObject();
}
writer.WritePropertyName("unknownConfidence");
writer.WriteStartObject();
writer.WriteNumber("initial", config.UnknownConfidence.Initial);
writer.WriteNumber("decayPerDay", config.UnknownConfidence.DecayPerDay);
writer.WriteNumber("floor", config.UnknownConfidence.Floor);
if (!config.UnknownConfidence.Bands.IsDefaultOrEmpty)
{
writer.WritePropertyName("bands");
writer.WriteStartArray();
foreach (var band in config.UnknownConfidence.Bands
.OrderByDescending(static b => b.Min)
.ThenBy(static b => b.Name, StringComparer.OrdinalIgnoreCase))
{
writer.WriteStartObject();
writer.WriteString("name", band.Name);
writer.WriteNumber("min", band.Min);
if (!string.IsNullOrWhiteSpace(band.Description))
{
writer.WriteString("description", band.Description);
}
writer.WriteEndObject();
}
writer.WriteEndArray();
}
writer.WriteEndObject();
writer.WriteEndObject();
writer.Flush();
}
}

View File

@@ -0,0 +1,27 @@
using System;
using System.IO;
using System.Reflection;
using System.Text;
using System.Threading;
using Json.Schema;
namespace StellaOps.Policy;
public static class PolicyScoringSchema
{
private const string SchemaResourceName = "StellaOps.Policy.Schemas.policy-scoring-schema@1.json";
private static readonly Lazy<JsonSchema> CachedSchema = new(LoadSchema, LazyThreadSafetyMode.ExecutionAndPublication);
public static JsonSchema Schema => CachedSchema.Value;
private static JsonSchema LoadSchema()
{
var assembly = Assembly.GetExecutingAssembly();
using var stream = assembly.GetManifestResourceStream(SchemaResourceName)
?? throw new InvalidOperationException($"Embedded resource '{SchemaResourceName}' was not found.");
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
var schemaJson = reader.ReadToEnd();
return JsonSchema.FromText(schemaJson);
}
}

View File

@@ -0,0 +1,29 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicySnapshot(
long RevisionNumber,
string RevisionId,
string Digest,
DateTimeOffset CreatedAt,
string? CreatedBy,
PolicyDocumentFormat Format,
PolicyDocument Document,
ImmutableArray<PolicyIssue> Issues,
PolicyScoringConfig ScoringConfig);
public sealed record PolicySnapshotContent(
string Content,
PolicyDocumentFormat Format,
string? Actor,
string? Source,
string? Description);
public sealed record PolicySnapshotSaveResult(
bool Success,
bool Created,
string Digest,
PolicySnapshot? Snapshot,
PolicyBindingResult BindingResult);

View File

@@ -0,0 +1,101 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy;
public sealed class PolicySnapshotStore
{
private readonly IPolicySnapshotRepository _snapshotRepository;
private readonly IPolicyAuditRepository _auditRepository;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PolicySnapshotStore> _logger;
private readonly SemaphoreSlim _mutex = new(1, 1);
public PolicySnapshotStore(
IPolicySnapshotRepository snapshotRepository,
IPolicyAuditRepository auditRepository,
TimeProvider? timeProvider,
ILogger<PolicySnapshotStore> logger)
{
_snapshotRepository = snapshotRepository ?? throw new ArgumentNullException(nameof(snapshotRepository));
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PolicySnapshotSaveResult> SaveAsync(PolicySnapshotContent content, CancellationToken cancellationToken = default)
{
if (content is null)
{
throw new ArgumentNullException(nameof(content));
}
var bindingResult = PolicyBinder.Bind(content.Content, content.Format);
if (!bindingResult.Success)
{
_logger.LogWarning("Policy snapshot rejected due to validation errors (Format: {Format})", content.Format);
return new PolicySnapshotSaveResult(false, false, string.Empty, null, bindingResult);
}
var digest = PolicyDigest.Compute(bindingResult.Document);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var latest = await _snapshotRepository.GetLatestAsync(cancellationToken).ConfigureAwait(false);
if (latest is not null && string.Equals(latest.Digest, digest, StringComparison.Ordinal))
{
_logger.LogInformation("Policy snapshot unchanged; digest {Digest} matches revision {RevisionId}", digest, latest.RevisionId);
return new PolicySnapshotSaveResult(true, false, digest, latest, bindingResult);
}
var revisionNumber = (latest?.RevisionNumber ?? 0) + 1;
var revisionId = $"rev-{revisionNumber}";
var createdAt = _timeProvider.GetUtcNow();
var scoringConfig = PolicyScoringConfig.Default;
var snapshot = new PolicySnapshot(
revisionNumber,
revisionId,
digest,
createdAt,
content.Actor,
content.Format,
bindingResult.Document,
bindingResult.Issues,
scoringConfig);
await _snapshotRepository.AddAsync(snapshot, cancellationToken).ConfigureAwait(false);
var auditMessage = content.Description ?? "Policy snapshot created";
var auditEntry = new PolicyAuditEntry(
Guid.NewGuid(),
createdAt,
"snapshot.created",
revisionId,
digest,
content.Actor,
auditMessage);
await _auditRepository.AddAsync(auditEntry, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Policy snapshot saved. Revision {RevisionId}, digest {Digest}, issues {IssueCount}",
revisionId,
digest,
bindingResult.Issues.Length);
return new PolicySnapshotSaveResult(true, true, digest, snapshot, bindingResult);
}
finally
{
_mutex.Release();
}
}
public Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default)
=> _snapshotRepository.GetLatestAsync(cancellationToken);
}

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicyUnknownConfidenceConfig(
double Initial,
double DecayPerDay,
double Floor,
ImmutableArray<PolicyUnknownConfidenceBand> Bands)
{
public double Clamp(double value)
=> Math.Clamp(value, Floor, 1.0);
public PolicyUnknownConfidenceBand ResolveBand(double value)
{
if (Bands.IsDefaultOrEmpty)
{
return PolicyUnknownConfidenceBand.Default;
}
foreach (var band in Bands)
{
if (value >= band.Min)
{
return band;
}
}
return Bands[Bands.Length - 1];
}
}
public sealed record PolicyUnknownConfidenceBand(string Name, double Min, string? Description = null)
{
public static PolicyUnknownConfidenceBand Default { get; } = new("unspecified", 0, null);
}

View File

@@ -0,0 +1,242 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public sealed record PolicyValidationCliOptions
{
public IReadOnlyList<string> Inputs { get; init; } = Array.Empty<string>();
/// <summary>
/// Writes machine-readable JSON instead of human-formatted text.
/// </summary>
public bool OutputJson { get; init; }
/// <summary>
/// When enabled, warnings cause a non-zero exit code.
/// </summary>
public bool Strict { get; init; }
}
public sealed record PolicyValidationFileResult(
string Path,
PolicyBindingResult BindingResult,
PolicyDiagnosticsReport Diagnostics);
public sealed class PolicyValidationCli
{
private readonly TextWriter _output;
private readonly TextWriter _error;
public PolicyValidationCli(TextWriter? output = null, TextWriter? error = null)
{
_output = output ?? Console.Out;
_error = error ?? Console.Error;
}
public async Task<int> RunAsync(PolicyValidationCliOptions options, CancellationToken cancellationToken = default)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
if (options.Inputs.Count == 0)
{
await _error.WriteLineAsync("No input files provided. Supply one or more policy file paths.");
return 64; // EX_USAGE
}
var results = new List<PolicyValidationFileResult>();
foreach (var input in options.Inputs)
{
cancellationToken.ThrowIfCancellationRequested();
var resolvedPaths = ResolveInput(input);
if (resolvedPaths.Count == 0)
{
await _error.WriteLineAsync($"No files matched '{input}'.");
continue;
}
foreach (var path in resolvedPaths)
{
cancellationToken.ThrowIfCancellationRequested();
var format = PolicySchema.DetectFormat(path);
var content = await File.ReadAllTextAsync(path, cancellationToken);
var bindingResult = PolicyBinder.Bind(content, format);
var diagnostics = PolicyDiagnostics.Create(bindingResult);
results.Add(new PolicyValidationFileResult(path, bindingResult, diagnostics));
}
}
if (results.Count == 0)
{
await _error.WriteLineAsync("No files were processed.");
return 65; // EX_DATAERR
}
if (options.OutputJson)
{
WriteJson(results);
}
else
{
await WriteTextAsync(results, cancellationToken);
}
var hasErrors = results.Any(static result => !result.BindingResult.Success);
var hasWarnings = results.Any(static result => result.BindingResult.Issues.Any(static issue => issue.Severity == PolicyIssueSeverity.Warning));
if (hasErrors)
{
return 1;
}
if (options.Strict && hasWarnings)
{
return 2;
}
return 0;
}
private async Task WriteTextAsync(IReadOnlyList<PolicyValidationFileResult> results, CancellationToken cancellationToken)
{
foreach (var result in results)
{
cancellationToken.ThrowIfCancellationRequested();
var relativePath = MakeRelative(result.Path);
await _output.WriteLineAsync($"{relativePath} [{result.BindingResult.Format}]");
if (result.BindingResult.Issues.Length == 0)
{
await _output.WriteLineAsync(" OK");
continue;
}
foreach (var issue in result.BindingResult.Issues)
{
var severity = issue.Severity.ToString().ToUpperInvariant().PadRight(7);
await _output.WriteLineAsync($" {severity} {issue.Path} :: {issue.Message} ({issue.Code})");
}
}
}
private void WriteJson(IReadOnlyList<PolicyValidationFileResult> results)
{
var payload = results.Select(static result => new
{
path = result.Path,
format = result.BindingResult.Format.ToString().ToLowerInvariant(),
success = result.BindingResult.Success,
issues = result.BindingResult.Issues.Select(static issue => new
{
code = issue.Code,
message = issue.Message,
severity = issue.Severity.ToString().ToLowerInvariant(),
path = issue.Path,
}),
diagnostics = new
{
version = result.Diagnostics.Version,
ruleCount = result.Diagnostics.RuleCount,
errorCount = result.Diagnostics.ErrorCount,
warningCount = result.Diagnostics.WarningCount,
generatedAt = result.Diagnostics.GeneratedAt,
recommendations = result.Diagnostics.Recommendations,
},
})
.ToArray();
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
WriteIndented = true,
});
_output.WriteLine(json);
}
private static IReadOnlyList<string> ResolveInput(string input)
{
if (string.IsNullOrWhiteSpace(input))
{
return Array.Empty<string>();
}
var expanded = Environment.ExpandEnvironmentVariables(input.Trim());
if (File.Exists(expanded))
{
return new[] { Path.GetFullPath(expanded) };
}
if (Directory.Exists(expanded))
{
return Directory.EnumerateFiles(expanded, "*.*", SearchOption.TopDirectoryOnly)
.Where(static path => MatchesPolicyExtension(path))
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
.Select(Path.GetFullPath)
.ToArray();
}
var directory = Path.GetDirectoryName(expanded);
var searchPattern = Path.GetFileName(expanded);
if (string.IsNullOrEmpty(searchPattern))
{
return Array.Empty<string>();
}
if (string.IsNullOrEmpty(directory))
{
directory = ".";
}
if (!Directory.Exists(directory))
{
return Array.Empty<string>();
}
return Directory.EnumerateFiles(directory, searchPattern, SearchOption.TopDirectoryOnly)
.Where(static path => MatchesPolicyExtension(path))
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
.Select(Path.GetFullPath)
.ToArray();
}
private static bool MatchesPolicyExtension(string path)
{
var extension = Path.GetExtension(path);
return extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".yml", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".json", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".stella", StringComparison.OrdinalIgnoreCase);
}
private static string MakeRelative(string path)
{
try
{
var fullPath = Path.GetFullPath(path);
var current = Directory.GetCurrentDirectory();
if (fullPath.StartsWith(current, StringComparison.OrdinalIgnoreCase))
{
return fullPath[current.Length..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
return fullPath;
}
catch
{
return path;
}
}
}

View File

@@ -0,0 +1,112 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Policy;
public enum PolicyVerdictStatus
{
Pass,
Blocked,
Ignored,
Warned,
Deferred,
Escalated,
RequiresVex,
}
public sealed record PolicyVerdict(
string FindingId,
PolicyVerdictStatus Status,
string? RuleName = null,
string? RuleAction = null,
string? Notes = null,
double Score = 0,
string ConfigVersion = "1.0",
ImmutableDictionary<string, double>? Inputs = null,
string? QuietedBy = null,
bool Quiet = false,
double? UnknownConfidence = null,
string? ConfidenceBand = null,
double? UnknownAgeDays = null,
string? SourceTrust = null,
string? Reachability = null)
{
public static PolicyVerdict CreateBaseline(string findingId, PolicyScoringConfig scoringConfig)
{
var inputs = ImmutableDictionary<string, double>.Empty;
return new PolicyVerdict(
findingId,
PolicyVerdictStatus.Pass,
RuleName: null,
RuleAction: null,
Notes: null,
Score: 0,
ConfigVersion: scoringConfig.Version,
Inputs: inputs,
QuietedBy: null,
Quiet: false,
UnknownConfidence: null,
ConfidenceBand: null,
UnknownAgeDays: null,
SourceTrust: null,
Reachability: null);
}
public ImmutableDictionary<string, double> GetInputs()
=> Inputs ?? ImmutableDictionary<string, double>.Empty;
}
public sealed record PolicyVerdictDiff(
PolicyVerdict Baseline,
PolicyVerdict Projected)
{
public bool Changed
{
get
{
if (Baseline.Status != Projected.Status)
{
return true;
}
if (!string.Equals(Baseline.RuleName, Projected.RuleName, StringComparison.Ordinal))
{
return true;
}
if (Math.Abs(Baseline.Score - Projected.Score) > 0.0001)
{
return true;
}
if (!string.Equals(Baseline.QuietedBy, Projected.QuietedBy, StringComparison.Ordinal))
{
return true;
}
var baselineConfidence = Baseline.UnknownConfidence ?? 0;
var projectedConfidence = Projected.UnknownConfidence ?? 0;
if (Math.Abs(baselineConfidence - projectedConfidence) > 0.0001)
{
return true;
}
if (!string.Equals(Baseline.ConfidenceBand, Projected.ConfidenceBand, StringComparison.Ordinal))
{
return true;
}
if (!string.Equals(Baseline.SourceTrust, Projected.SourceTrust, StringComparison.Ordinal))
{
return true;
}
if (!string.Equals(Baseline.Reachability, Projected.Reachability, StringComparison.Ordinal))
{
return true;
}
return false;
}
}
}

View File

@@ -0,0 +1,277 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.stella-ops.org/policy/policy-schema@1.json",
"title": "StellaOps Policy Schema v1",
"type": "object",
"required": ["version", "rules"],
"properties": {
"version": {
"type": ["string", "number"],
"enum": ["1", "1.0", 1, 1.0]
},
"description": {
"type": "string"
},
"metadata": {
"type": "object",
"additionalProperties": {
"type": ["string", "number", "boolean"]
}
},
"exceptions": {
"type": "object",
"properties": {
"effects": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/exceptionEffect"
},
"uniqueItems": true
},
"routingTemplates": {
"type": "array",
"items": {
"$ref": "#/$defs/exceptionRoutingTemplate"
},
"uniqueItems": true
}
},
"additionalProperties": false
},
"rules": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/rule"
}
}
},
"additionalProperties": true,
"$defs": {
"identifier": {
"type": "string",
"minLength": 1
},
"severity": {
"type": "string",
"enum": ["Critical", "High", "Medium", "Low", "Informational", "None", "Unknown"]
},
"stringArray": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true
},
"exceptionEffect": {
"type": "object",
"required": ["id", "effect"],
"properties": {
"id": {
"$ref": "#/$defs/identifier"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"effect": {
"type": "string",
"enum": ["suppress", "defer", "downgrade", "requireControl"]
},
"downgradeSeverity": {
"$ref": "#/$defs/severity"
},
"requiredControlId": {
"$ref": "#/$defs/identifier"
},
"routingTemplate": {
"$ref": "#/$defs/identifier"
},
"maxDurationDays": {
"type": "integer",
"minimum": 1
}
},
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"effect": {
"const": "downgrade"
}
},
"required": ["effect"]
},
"then": {
"required": ["downgradeSeverity"]
}
},
{
"if": {
"properties": {
"effect": {
"const": "requireControl"
}
},
"required": ["effect"]
},
"then": {
"required": ["requiredControlId"]
}
}
]
},
"exceptionRoutingTemplate": {
"type": "object",
"required": ["id", "authorityRouteId"],
"properties": {
"id": {
"$ref": "#/$defs/identifier"
},
"description": {
"type": "string"
},
"authorityRouteId": {
"$ref": "#/$defs/identifier"
},
"requireMfa": {
"type": "boolean"
}
},
"additionalProperties": false
},
"rule": {
"type": "object",
"required": ["name", "action"],
"properties": {
"id": {
"$ref": "#/$defs/identifier"
},
"name": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string"
},
"severity": {
"type": "array",
"items": {
"$ref": "#/$defs/severity"
},
"uniqueItems": true
},
"sources": {
"$ref": "#/$defs/stringArray"
},
"vendors": {
"$ref": "#/$defs/stringArray"
},
"licenses": {
"$ref": "#/$defs/stringArray"
},
"tags": {
"$ref": "#/$defs/stringArray"
},
"environments": {
"$ref": "#/$defs/stringArray"
},
"images": {
"$ref": "#/$defs/stringArray"
},
"repositories": {
"$ref": "#/$defs/stringArray"
},
"packages": {
"$ref": "#/$defs/stringArray"
},
"purls": {
"$ref": "#/$defs/stringArray"
},
"cves": {
"$ref": "#/$defs/stringArray"
},
"paths": {
"$ref": "#/$defs/stringArray"
},
"layerDigests": {
"$ref": "#/$defs/stringArray"
},
"usedByEntrypoint": {
"$ref": "#/$defs/stringArray"
},
"justification": {
"type": "string"
},
"quiet": {
"type": "boolean"
},
"action": {
"oneOf": [
{
"type": "string",
"enum": ["block", "fail", "deny", "ignore", "warn", "defer", "escalate", "requireVex"]
},
{
"type": "object",
"required": ["type"],
"properties": {
"type": {
"type": "string"
},
"quiet": {
"type": "boolean"
},
"until": {
"type": "string",
"format": "date-time"
},
"justification": {
"type": "string"
},
"severity": {
"$ref": "#/$defs/severity"
},
"vendors": {
"$ref": "#/$defs/stringArray"
},
"justifications": {
"$ref": "#/$defs/stringArray"
},
"epss": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"kev": {
"type": "boolean"
}
},
"additionalProperties": true
}
]
},
"expires": {
"type": "string",
"format": "date-time"
},
"until": {
"type": "string",
"format": "date-time"
},
"metadata": {
"type": "object",
"additionalProperties": {
"type": ["string", "number", "boolean"]
}
}
},
"additionalProperties": true
}
}
}

View File

@@ -0,0 +1,51 @@
{
"version": "1.0",
"severityWeights": {
"Critical": 90.0,
"High": 75.0,
"Medium": 50.0,
"Low": 25.0,
"Informational": 10.0,
"None": 0.0,
"Unknown": 60.0
},
"quietPenalty": 45.0,
"warnPenalty": 15.0,
"ignorePenalty": 35.0,
"trustOverrides": {
"vendor": 1.0,
"distro": 0.85,
"platform": 0.75,
"community": 0.65
},
"reachabilityBuckets": {
"entrypoint": 1.0,
"direct": 0.85,
"indirect": 0.6,
"runtime": 0.45,
"unreachable": 0.25,
"unknown": 0.5
},
"unknownConfidence": {
"initial": 0.8,
"decayPerDay": 0.05,
"floor": 0.2,
"bands": [
{
"name": "high",
"min": 0.65,
"description": "Fresh unknowns with recent telemetry."
},
{
"name": "medium",
"min": 0.35,
"description": "Unknowns aging toward action required."
},
{
"name": "low",
"min": 0.0,
"description": "Stale unknowns that must be triaged."
}
]
}
}

View File

@@ -0,0 +1,156 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.stella-ops.org/policy/policy-scoring-schema@1.json",
"title": "StellaOps Policy Scoring Configuration v1",
"type": "object",
"additionalProperties": false,
"required": [
"version",
"severityWeights"
],
"properties": {
"version": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+$"
},
"severityWeights": {
"type": "object",
"additionalProperties": false,
"required": [
"Critical",
"High",
"Medium",
"Low",
"Informational",
"None",
"Unknown"
],
"properties": {
"Critical": {
"$ref": "#/$defs/weight"
},
"High": {
"$ref": "#/$defs/weight"
},
"Medium": {
"$ref": "#/$defs/weight"
},
"Low": {
"$ref": "#/$defs/weight"
},
"Informational": {
"$ref": "#/$defs/weight"
},
"None": {
"$ref": "#/$defs/weight"
},
"Unknown": {
"$ref": "#/$defs/weight"
}
}
},
"quietPenalty": {
"$ref": "#/$defs/penalty"
},
"warnPenalty": {
"$ref": "#/$defs/penalty"
},
"ignorePenalty": {
"$ref": "#/$defs/penalty"
},
"trustOverrides": {
"type": "object",
"propertyNames": {
"pattern": "^[a-z][a-z0-9_.-]*$"
},
"additionalProperties": {
"$ref": "#/$defs/trustWeight"
}
},
"reachabilityBuckets": {
"type": "object",
"minProperties": 1,
"propertyNames": {
"pattern": "^[a-z][a-z0-9_.-]*$"
},
"additionalProperties": {
"$ref": "#/$defs/reachabilityWeight"
}
},
"unknownConfidence": {
"type": "object",
"additionalProperties": false,
"required": [
"initial",
"decayPerDay",
"floor",
"bands"
],
"properties": {
"initial": {
"$ref": "#/$defs/confidence"
},
"decayPerDay": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"floor": {
"$ref": "#/$defs/confidence"
},
"bands": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"min"
],
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z][a-z0-9_.-]*$"
},
"min": {
"$ref": "#/$defs/confidence"
},
"description": {
"type": "string",
"maxLength": 256
}
}
}
}
}
}
},
"$defs": {
"weight": {
"type": "number",
"minimum": 0,
"maximum": 100
},
"penalty": {
"type": "number",
"minimum": 0,
"maximum": 200
},
"trustWeight": {
"type": "number",
"minimum": 0,
"maximum": 5
},
"reachabilityWeight": {
"type": "number",
"minimum": 0,
"maximum": 1.5
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1
}
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.0-beta5.25306.1" />
<PackageReference Include="YamlDotNet" Version="13.7.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<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>

View File

@@ -0,0 +1,14 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public interface IPolicySnapshotRepository
{
Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<PolicySnapshot>> ListAsync(int limit, CancellationToken cancellationToken = default);
Task AddAsync(PolicySnapshot snapshot, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public sealed class InMemoryPolicySnapshotRepository : IPolicySnapshotRepository
{
private readonly List<PolicySnapshot> _snapshots = new();
private readonly SemaphoreSlim _mutex = new(1, 1);
public async Task AddAsync(PolicySnapshot snapshot, CancellationToken cancellationToken = default)
{
if (snapshot is null)
{
throw new ArgumentNullException(nameof(snapshot));
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_snapshots.Add(snapshot);
_snapshots.Sort(static (left, right) => left.RevisionNumber.CompareTo(right.RevisionNumber));
}
finally
{
_mutex.Release();
}
}
public async Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default)
{
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return _snapshots.Count == 0 ? null : _snapshots[^1];
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<PolicySnapshot>> ListAsync(int limit, CancellationToken cancellationToken = default)
{
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
IEnumerable<PolicySnapshot> query = _snapshots;
if (limit > 0)
{
query = query.TakeLast(limit);
}
return query.ToImmutableArray();
}
finally
{
_mutex.Release();
}
}
}

View File

@@ -0,0 +1,38 @@
# Policy Engine Task Board — Epic 1: Aggregation-Only Contract
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-AOC-19-001 | TODO | Policy Guild | WEB-AOC-19-003 | Add Roslyn/CI lint preventing ingestion projects from referencing Policy merge/severity helpers; block forbidden writes at compile time. | Analyzer fails builds when ingestion projects set banned keys or reference Policy-only namespaces; CI pipeline wired. |
| POLICY-AOC-19-002 | TODO | Policy Guild, Platform Security | AUTH-AOC-19-001 | Enforce `effective_finding_*` write gate ensuring only Policy Engine identity can create/update materializations. | Guard rejects non-Policy identities with `ERR_AOC_006`; integration tests validate authorized writes; logs contain audit trail. |
| POLICY-AOC-19-003 | TODO | Policy Guild | CONCELIER-CORE-AOC-19-004, EXCITITOR-CORE-AOC-19-004 | Update readers/processors to consume only `content.raw`, `identifiers`, and `linkset`. Remove dependencies on legacy normalized fields and refresh fixtures. | All policy pipelines pass tests using raw inputs; fixture diff shows no derived data persisted in ingestion; docs updated. |
| POLICY-AOC-19-004 | TODO | Policy Guild, QA Guild | POLICY-AOC-19-003 | Add regression tests ensuring policy derived outputs remain deterministic when ingesting revised raw docs (supersedes) and when violations occur. | Determinism suite passes; new fixtures prove policy recomputation handles append-only raw data and surfaces guard violations. |
> Epic 2 service implementation tasks now live under `src/Policy/StellaOps.Policy.Engine/TASKS.md`. Keep library-specific work in this file.
## Policy Engine + Editor v1 (Epic 5)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-SPL-23-001 | TODO | Policy Guild, Language Infrastructure Guild | POLICY-ENGINE-20-001 | Define SPL v1 YAML + JSON Schema, including advisory rules, VEX precedence, severity mapping, exceptions, and layering metadata. Publish schema resources and validation fixtures. | Schema files committed under `Schemas/`; validation tests cover representative policies; documentation draft ready. |
| POLICY-SPL-23-002 | TODO | Policy Guild | POLICY-SPL-23-001 | Implement canonicalizer that normalizes policy packs (ordering, defaults), computes content hash, and prepares bundle metadata for AOC/signing. | Canonicalizer produces deterministic output (tests across permutations); hash matches spec; integration wired into compiler. |
| POLICY-SPL-23-003 | TODO | Policy Guild | POLICY-SPL-23-001 | Build policy layering/override engine (global/org/project/env/exception) with field-level precedence matrices; add unit/property tests. | Layering engine honors override rules, rejects widening overrides; property tests ensure determinism. |
| POLICY-SPL-23-004 | TODO | Policy Guild, Audit Guild | POLICY-SPL-23-002 | Design explanation tree model (rule hits, inputs, decisions) and persistence structures reused by runtime, UI, and CLI. | Explanation DTOs published; serialization deterministic; tests cover nested explanations. |
| POLICY-SPL-23-005 | TODO | Policy Guild, DevEx Guild | POLICY-SPL-23-001 | Create migration tool to snapshot existing behavior into baseline SPL packs (`org.core.baseline`), including policy docs and sample bundles. | Tool emits baseline policy pack + tests verifying parity against legacy behavior; documentation updated. |
## Exceptions v1 (Epic 7)
| 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. |
## Reachability v1 (Epic 8)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-SPL-24-001 | TODO | Policy Guild, Signals Guild | SIGNALS-24-004 | Extend SPL schema to expose reachability/exploitability predicates and weighting functions; update documentation and fixtures. | Schema updated; validation fixtures for reachability rules; docs ready for review. |
## Risk Profiles (Epic 18)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-RISK-66-004 | TODO | Policy Guild, Risk Profile Schema Guild | POLICY-RISK-66-001 | Extend Policy libraries to load/save RiskProfile documents, compute content hashes, and surface validation diagnostics. | Libraries serialize/deserialize profiles; hash deterministic; tests cover invalid input. |
| POLICY-RISK-67-003 | TODO | Policy Guild, Risk Engine Guild | POLICY-RISK-66-004, RISK-ENGINE-69-001 | Provide policy-layer APIs to trigger risk simulations and return distributions/contribution breakdowns. | API returns simulation payload; golden tests match expected output. |
| POLICY-RISK-68-002 | TODO | Policy Guild, Export Guild | POLICY-RISK-66-004 | Enable exporting/importing RiskProfiles with signatures via policy tooling (CLI + API). | Export/import round-trip tested; signatures verified; docs updated. |

View File

@@ -0,0 +1,104 @@
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
using Xunit;
using Xunit.Sdk;
namespace StellaOps.Policy.Engine.Tests;
public sealed class PolicyCompilerTests
{
[Fact]
public void Compile_BaselinePolicy_Succeeds()
{
const string source = """
policy "Baseline Production Policy" syntax "stella-dsl@1" {
metadata {
description = "Block critical, escalate high, enforce VEX justifications."
tags = ["baseline","production"]
}
profile severity {
map vendor_weight {
source "GHSA" => +0.5
source "OSV" => +0.0
}
env exposure_adjustments {
if env.exposure == "internet" then +0.5
}
}
rule block_critical priority 5 {
when severity.normalized >= "Critical"
then status := "blocked"
because "Critical severity must be remediated before deploy."
}
rule escalate_high_internet {
when severity.normalized == "High"
and env.exposure == "internet"
then escalate to severity_band("Critical")
because "High severity on internet-exposed asset escalates to critical."
}
rule require_vex_justification {
when vex.any(status in ["not_affected","fixed"])
and vex.justification in ["component_not_present","vulnerable_code_not_present"]
then status := vex.status
annotate winning_statement := vex.latest().statementId
because "Respect strong vendor VEX claims."
}
rule alert_warn_eol_runtime priority 1 {
when severity.normalized <= "Medium"
and sbom.has_tag("runtime:eol")
then warn message "Runtime marked as EOL; upgrade recommended."
because "Deprecated runtime should be upgraded."
}
}
""";
var compiler = new PolicyCompiler();
var result = compiler.Compile(source);
if (!result.Success)
{
throw new Xunit.Sdk.XunitException($"Compilation failed: {Describe(result.Diagnostics)}");
}
Assert.False(string.IsNullOrWhiteSpace(result.Checksum));
Assert.NotEmpty(result.CanonicalRepresentation);
Assert.All(result.Diagnostics, issue => Assert.NotEqual(PolicyIssueSeverity.Error, issue.Severity));
var document = Assert.IsType<PolicyIrDocument>(result.Document);
Assert.Equal("Baseline Production Policy", document.Name);
Assert.Equal("stella-dsl@1", document.Syntax);
Assert.Equal(4, document.Rules.Length);
Assert.Single(document.Profiles);
var firstAction = Assert.IsType<PolicyIrAssignmentAction>(document.Rules[0].ThenActions[0]);
Assert.Equal("status", firstAction.Target[0]);
}
[Fact]
public void Compile_MissingBecause_ReportsDiagnostic()
{
const string source = """
policy "Incomplete" syntax "stella-dsl@1" {
rule missing_because {
when true
then status := "suppressed"
}
}
""";
var compiler = new PolicyCompiler();
var result = compiler.Compile(source);
Assert.False(result.Success);
PolicyIssue diagnostic = result.Diagnostics.First(issue => issue.Code == "POLICY-DSL-PARSE-006");
Assert.Equal(PolicyIssueSeverity.Error, diagnostic.Severity);
}
private static string Describe(ImmutableArray<PolicyIssue> issues) =>
string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}"));
}

View File

@@ -0,0 +1,291 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Evaluation;
using StellaOps.Policy.Engine.Services;
using Xunit;
using Xunit.Sdk;
namespace StellaOps.Policy.Engine.Tests;
public sealed class PolicyEvaluatorTests
{
private static readonly string BaselinePolicy = """
policy "Baseline Production Policy" syntax "stella-dsl@1" {
metadata {
description = "Block critical, escalate high, enforce VEX justifications."
tags = ["baseline","production"]
}
profile severity {
map vendor_weight {
source "GHSA" => +0.5
source "OSV" => +0.0
}
env exposure_adjustments {
if env.exposure == "internet" then +0.5
}
}
rule block_critical priority 5 {
when severity.normalized >= "Critical"
then status := "blocked"
because "Critical severity must be remediated before deploy."
}
rule escalate_high_internet {
when severity.normalized == "High"
and env.exposure == "internet"
then escalate to severity_band("Critical")
because "High severity on internet-exposed asset escalates to critical."
}
rule require_vex_justification {
when vex.any(status in ["not_affected","fixed"])
and vex.justification in ["component_not_present","vulnerable_code_not_present"]
then status := vex.status
annotate winning_statement := vex.latest().statementId
because "Respect strong vendor VEX claims."
}
rule alert_warn_eol_runtime priority 1 {
when severity.normalized <= "Medium"
and sbom.has_tag("runtime:eol")
then warn message "Runtime marked as EOL; upgrade recommended."
because "Deprecated runtime should be upgraded."
}
}
""";
private readonly PolicyCompiler compiler = new();
private readonly PolicyEvaluationService evaluationService = new();
[Fact]
public void Evaluate_BlockCriticalRuleMatches()
{
var document = CompileBaseline();
var context = CreateContext(severity: "Critical", exposure: "internal");
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("block_critical", result.RuleName);
Assert.Equal("blocked", result.Status);
}
[Fact]
public void Evaluate_EscalateAdjustsSeverity()
{
var document = CompileBaseline();
var context = CreateContext(severity: "High", exposure: "internet");
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("escalate_high_internet", result.RuleName);
Assert.Equal("affected", result.Status);
Assert.Equal("Critical", result.Severity);
}
[Fact]
public void Evaluate_VexOverrideSetsStatusAndAnnotation()
{
var document = CompileBaseline();
var statements = ImmutableArray.Create(
new PolicyEvaluationVexStatement("not_affected", "component_not_present", "stmt-001"));
var context = CreateContext("Medium", "internal") with
{
Vex = new PolicyEvaluationVexEvidence(statements)
};
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("require_vex_justification", result.RuleName);
Assert.Equal("not_affected", result.Status);
Assert.Equal("stmt-001", result.Annotations["winning_statement"]);
}
[Fact]
public void Evaluate_WarnRuleEmitsWarning()
{
var document = CompileBaseline();
var tags = ImmutableHashSet.Create("runtime:eol");
var context = CreateContext("Medium", "internal") with
{
Sbom = new PolicyEvaluationSbom(tags)
};
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("alert_warn_eol_runtime", result.RuleName);
Assert.Equal("warned", result.Status);
Assert.Contains(result.Warnings, message => message.Contains("EOL", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Evaluate_ExceptionSuppressesCriticalFinding()
{
var document = CompileBaseline();
var effect = new PolicyExceptionEffect(
Id: "suppress-critical",
Name: "Critical Break Glass",
Effect: PolicyExceptionEffectType.Suppress,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: "secops",
MaxDurationDays: 7,
Description: null);
var scope = PolicyEvaluationExceptionScope.Create(ruleNames: new[] { "block_critical" });
var instance = new PolicyEvaluationExceptionInstance(
Id: "exc-001",
EffectId: effect.Id,
Scope: scope,
CreatedAt: new DateTimeOffset(2025, 10, 1, 0, 0, 0, TimeSpan.Zero),
Metadata: ImmutableDictionary<string, string>.Empty);
var exceptions = new PolicyEvaluationExceptions(
ImmutableDictionary<string, PolicyExceptionEffect>.Empty.Add(effect.Id, effect),
ImmutableArray.Create(instance));
var context = CreateContext("Critical", "internal", exceptions);
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("block_critical", result.RuleName);
Assert.Equal("suppressed", result.Status);
Assert.NotNull(result.AppliedException);
Assert.Equal("exc-001", result.AppliedException!.ExceptionId);
Assert.Equal("suppress-critical", result.AppliedException!.EffectId);
Assert.Equal("blocked", result.AppliedException!.OriginalStatus);
Assert.Equal("suppressed", result.AppliedException!.AppliedStatus);
Assert.Equal("suppressed", result.Annotations["exception.status"]);
}
[Fact]
public void Evaluate_ExceptionDowngradesSeverity()
{
var document = CompileBaseline();
var effect = new PolicyExceptionEffect(
Id: "downgrade-internet",
Name: "Downgrade High Internet",
Effect: PolicyExceptionEffectType.Downgrade,
DowngradeSeverity: PolicySeverity.Medium,
RequiredControlId: null,
RoutingTemplate: null,
MaxDurationDays: null,
Description: null);
var scope = PolicyEvaluationExceptionScope.Create(
ruleNames: new[] { "escalate_high_internet" },
severities: new[] { "High" },
sources: new[] { "GHSA" });
var instance = new PolicyEvaluationExceptionInstance(
Id: "exc-200",
EffectId: effect.Id,
Scope: scope,
CreatedAt: new DateTimeOffset(2025, 10, 2, 0, 0, 0, TimeSpan.Zero),
Metadata: ImmutableDictionary<string, string>.Empty);
var exceptions = new PolicyEvaluationExceptions(
ImmutableDictionary<string, PolicyExceptionEffect>.Empty.Add(effect.Id, effect),
ImmutableArray.Create(instance));
var context = CreateContext("High", "internet", exceptions);
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("escalate_high_internet", result.RuleName);
Assert.Equal("affected", result.Status);
Assert.Equal("Medium", result.Severity);
Assert.NotNull(result.AppliedException);
Assert.Equal("Critical", result.AppliedException!.OriginalSeverity);
Assert.Equal("Medium", result.AppliedException!.AppliedSeverity);
Assert.Equal("Medium", result.Annotations["exception.severity"]);
}
[Fact]
public void Evaluate_MoreSpecificExceptionWins()
{
var document = CompileBaseline();
var suppressGlobal = new PolicyExceptionEffect(
Id: "suppress-critical-global",
Name: "Global Critical Suppress",
Effect: PolicyExceptionEffectType.Suppress,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: null,
MaxDurationDays: null,
Description: null);
var suppressRule = new PolicyExceptionEffect(
Id: "suppress-critical-rule",
Name: "Rule Critical Suppress",
Effect: PolicyExceptionEffectType.Suppress,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: null,
MaxDurationDays: null,
Description: null);
var globalInstance = new PolicyEvaluationExceptionInstance(
Id: "exc-global",
EffectId: suppressGlobal.Id,
Scope: PolicyEvaluationExceptionScope.Create(severities: new[] { "Critical" }),
CreatedAt: new DateTimeOffset(2025, 9, 1, 0, 0, 0, TimeSpan.Zero),
Metadata: ImmutableDictionary<string, string>.Empty);
var ruleInstance = new PolicyEvaluationExceptionInstance(
Id: "exc-rule",
EffectId: suppressRule.Id,
Scope: PolicyEvaluationExceptionScope.Create(
ruleNames: new[] { "block_critical" },
severities: new[] { "Critical" }),
CreatedAt: new DateTimeOffset(2025, 10, 5, 0, 0, 0, TimeSpan.Zero),
Metadata: ImmutableDictionary<string, string>.Empty.Add("requestedBy", "alice"));
var effects = ImmutableDictionary<string, PolicyExceptionEffect>.Empty
.Add(suppressGlobal.Id, suppressGlobal)
.Add(suppressRule.Id, suppressRule);
var exceptions = new PolicyEvaluationExceptions(
effects,
ImmutableArray.Create(globalInstance, ruleInstance));
var context = CreateContext("Critical", "internal", exceptions);
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("suppressed", result.Status);
Assert.NotNull(result.AppliedException);
Assert.Equal("exc-rule", result.AppliedException!.ExceptionId);
Assert.Equal("Rule Critical Suppress", result.AppliedException!.Metadata["effectName"]);
Assert.Equal("alice", result.AppliedException!.Metadata["requestedBy"]);
Assert.Equal("alice", result.Annotations["exception.meta.requestedBy"]);
}
private PolicyIrDocument CompileBaseline()
{
var compilation = compiler.Compile(BaselinePolicy);
Assert.True(compilation.Success, Describe(compilation.Diagnostics));
return Assert.IsType<PolicyIrDocument>(compilation.Document);
}
private static PolicyEvaluationContext CreateContext(string severity, string exposure, PolicyEvaluationExceptions? exceptions = null)
{
return new PolicyEvaluationContext(
new PolicyEvaluationSeverity(severity),
new PolicyEvaluationEnvironment(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["exposure"] = exposure
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase)),
new PolicyEvaluationAdvisory("GHSA", ImmutableDictionary<string, string>.Empty),
PolicyEvaluationVexEvidence.Empty,
new PolicyEvaluationSbom(ImmutableHashSet<string>.Empty),
exceptions ?? PolicyEvaluationExceptions.Empty);
}
private static string Describe(ImmutableArray<PolicyIssue> issues) =>
string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}"));
}

View File

@@ -0,0 +1,44 @@
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Services;
using Xunit;
namespace StellaOps.Policy.Engine.Tests;
public class PolicyPackRepositoryTests
{
private readonly InMemoryPolicyPackRepository repository = new();
[Fact]
public async Task ActivateRevision_WithSingleApprover_ActivatesImmediately()
{
await repository.CreateAsync("pack-1", "Pack", CancellationToken.None);
await repository.UpsertRevisionAsync("pack-1", 1, requiresTwoPersonApproval: false, PolicyRevisionStatus.Approved, CancellationToken.None);
var result = await repository.RecordActivationAsync("pack-1", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
Assert.Equal(PolicyActivationResultStatus.Activated, result.Status);
Assert.NotNull(result.Revision);
Assert.Equal(PolicyRevisionStatus.Active, result.Revision!.Status);
Assert.Single(result.Revision.Approvals);
}
[Fact]
public async Task ActivateRevision_WithTwoPersonRequirement_ReturnsPendingUntilSecondApproval()
{
await repository.CreateAsync("pack-2", "Pack", CancellationToken.None);
await repository.UpsertRevisionAsync("pack-2", 1, requiresTwoPersonApproval: true, PolicyRevisionStatus.Approved, CancellationToken.None);
var first = await repository.RecordActivationAsync("pack-2", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
Assert.Equal(PolicyActivationResultStatus.PendingSecondApproval, first.Status);
Assert.Equal(PolicyRevisionStatus.Approved, first.Revision!.Status);
Assert.Single(first.Revision.Approvals);
var duplicate = await repository.RecordActivationAsync("pack-2", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
Assert.Equal(PolicyActivationResultStatus.DuplicateApproval, duplicate.Status);
var second = await repository.RecordActivationAsync("pack-2", 1, "bob", DateTimeOffset.UtcNow, null, CancellationToken.None);
Assert.Equal(PolicyActivationResultStatus.Activated, second.Status);
Assert.Equal(PolicyRevisionStatus.Active, second.Revision!.Status);
Assert.Equal(2, second.Revision.Approvals.Length);
}
}

View File

@@ -0,0 +1,14 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,548 @@
using System.Diagnostics.Metrics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using Polly.Utilities;
using StellaOps.Auth.Client;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Gateway.Clients;
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
using Xunit;
using Xunit.Sdk;
namespace StellaOps.Policy.Gateway.Tests;
public sealed class GatewayActivationTests
{
[Fact]
public async Task ActivateRevision_UsesServiceTokenFallback_And_RecordsMetrics()
{
await using var factory = new PolicyGatewayWebApplicationFactory();
var tokenClient = factory.Services.GetRequiredService<StubTokenClient>();
tokenClient.Reset();
var recordingHandler = factory.Services.GetRequiredService<RecordingPolicyEngineHandler>();
recordingHandler.Reset();
using var listener = new MeterListener();
var activationMeasurements = new List<(long Value, string Outcome, string Source)>();
var latencyMeasurements = new List<(double Value, string Outcome, string Source)>();
listener.InstrumentPublished += (instrument, meterListener) =>
{
if (instrument.Meter.Name != "StellaOps.Policy.Gateway")
{
return;
}
meterListener.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
{
if (instrument.Name != "policy_gateway_activation_requests_total")
{
return;
}
activationMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.SetMeasurementEventCallback<double>((instrument, value, tags, _) =>
{
if (instrument.Name != "policy_gateway_activation_latency_ms")
{
return;
}
latencyMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.Start();
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/api/policy/packs/example/revisions/5:activate",
new ActivatePolicyRevisionRequest("rollout window start"));
listener.Dispose();
var forwardedRequest = recordingHandler.LastRequest;
var issuedTokens = tokenClient.RequestCount;
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new Xunit.Sdk.XunitException(
$"Gateway response was {(int)response.StatusCode} {response.StatusCode}. " +
$"Body: {responseBody}. IssuedTokens: {issuedTokens}. Forwarded: { (forwardedRequest is null ? "no" : "yes") }.");
}
Assert.Equal(1, tokenClient.RequestCount);
Assert.NotNull(forwardedRequest);
Assert.Equal(HttpMethod.Post, forwardedRequest!.Method);
Assert.Equal("https://policy-engine.test/api/policy/packs/example/revisions/5:activate", forwardedRequest.RequestUri!.ToString());
Assert.Equal("Bearer", forwardedRequest.Headers.Authorization?.Scheme);
Assert.Equal("service-token", forwardedRequest.Headers.Authorization?.Parameter);
Assert.False(forwardedRequest.Headers.TryGetValues("DPoP", out _), "Expected no DPoP header when DPoP is disabled.");
Assert.Contains(activationMeasurements, measurement =>
measurement.Value == 1 &&
measurement.Outcome == "activated" &&
measurement.Source == "service");
Assert.Contains(latencyMeasurements, measurement =>
measurement.Outcome == "activated" &&
measurement.Source == "service");
}
[Fact]
public async Task ActivateRevision_RecordsMetrics_WhenUpstreamReturnsUnauthorized()
{
await using var factory = new PolicyGatewayWebApplicationFactory();
var tokenClient = factory.Services.GetRequiredService<StubTokenClient>();
tokenClient.Reset();
var recordingHandler = factory.Services.GetRequiredService<RecordingPolicyEngineHandler>();
recordingHandler.Reset();
recordingHandler.SetResponseFactory(_ =>
{
var problem = new ProblemDetails
{
Title = "Unauthorized",
Detail = "Caller token rejected.",
Status = StatusCodes.Status401Unauthorized
};
return new HttpResponseMessage(HttpStatusCode.Unauthorized)
{
Content = JsonContent.Create(problem)
};
});
using var listener = new MeterListener();
var activationMeasurements = new List<(long Value, string Outcome, string Source)>();
var latencyMeasurements = new List<(double Value, string Outcome, string Source)>();
listener.InstrumentPublished += (instrument, meterListener) =>
{
if (instrument.Meter.Name != "StellaOps.Policy.Gateway")
{
return;
}
meterListener.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
{
if (instrument.Name != "policy_gateway_activation_requests_total")
{
return;
}
activationMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.SetMeasurementEventCallback<double>((instrument, value, tags, _) =>
{
if (instrument.Name != "policy_gateway_activation_latency_ms")
{
return;
}
latencyMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.Start();
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/api/policy/packs/example/revisions/2:activate",
new ActivatePolicyRevisionRequest("failure path"));
listener.Dispose();
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Equal(1, tokenClient.RequestCount);
var forwardedRequest = recordingHandler.LastRequest;
Assert.NotNull(forwardedRequest);
Assert.Equal("service-token", forwardedRequest!.Headers.Authorization?.Parameter);
Assert.Contains(activationMeasurements, measurement =>
measurement.Value == 1 &&
measurement.Outcome == "unauthorized" &&
measurement.Source == "service");
Assert.Contains(latencyMeasurements, measurement =>
measurement.Outcome == "unauthorized" &&
measurement.Source == "service");
}
[Fact]
public async Task ActivateRevision_RecordsMetrics_WhenUpstreamReturnsBadGateway()
{
await using var factory = new PolicyGatewayWebApplicationFactory();
var tokenClient = factory.Services.GetRequiredService<StubTokenClient>();
tokenClient.Reset();
var recordingHandler = factory.Services.GetRequiredService<RecordingPolicyEngineHandler>();
recordingHandler.Reset();
recordingHandler.SetResponseFactory(_ =>
{
var problem = new ProblemDetails
{
Title = "Upstream error",
Detail = "Policy Engine returned 502.",
Status = StatusCodes.Status502BadGateway
};
return new HttpResponseMessage(HttpStatusCode.BadGateway)
{
Content = JsonContent.Create(problem)
};
});
using var listener = new MeterListener();
var activationMeasurements = new List<(long Value, string Outcome, string Source)>();
var latencyMeasurements = new List<(double Value, string Outcome, string Source)>();
listener.InstrumentPublished += (instrument, meterListener) =>
{
if (instrument.Meter.Name != "StellaOps.Policy.Gateway")
{
return;
}
meterListener.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
{
if (instrument.Name != "policy_gateway_activation_requests_total")
{
return;
}
activationMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.SetMeasurementEventCallback<double>((instrument, value, tags, _) =>
{
if (instrument.Name != "policy_gateway_activation_latency_ms")
{
return;
}
latencyMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.Start();
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/api/policy/packs/example/revisions/3:activate",
new ActivatePolicyRevisionRequest("upstream failure"));
listener.Dispose();
Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode);
Assert.Equal(1, tokenClient.RequestCount);
var forwardedRequest = recordingHandler.LastRequest;
Assert.NotNull(forwardedRequest);
Assert.Equal("service-token", forwardedRequest!.Headers.Authorization?.Parameter);
Assert.Contains(activationMeasurements, measurement =>
measurement.Value == 1 &&
measurement.Outcome == "error" &&
measurement.Source == "service");
Assert.Contains(latencyMeasurements, measurement =>
measurement.Outcome == "error" &&
measurement.Source == "service");
}
[Fact]
public async Task ActivateRevision_RetriesOnTooManyRequests()
{
await using var factory = new PolicyGatewayWebApplicationFactory();
var recordedDelays = new List<TimeSpan>();
var originalSleep = SystemClock.SleepAsync;
SystemClock.SleepAsync = (delay, cancellationToken) =>
{
recordedDelays.Add(delay);
return Task.CompletedTask;
};
var tokenClient = factory.Services.GetRequiredService<StubTokenClient>();
tokenClient.Reset();
var recordingHandler = factory.Services.GetRequiredService<RecordingPolicyEngineHandler>();
recordingHandler.Reset();
recordingHandler.SetResponseSequence(new[]
{
CreateThrottleResponse(),
CreateThrottleResponse(),
RecordingPolicyEngineHandler.CreateSuccessResponse()
});
using var client = factory.CreateClient();
try
{
var response = await client.PostAsJsonAsync(
"/api/policy/packs/example/revisions/7:activate",
new ActivatePolicyRevisionRequest("retry after throttle"));
Assert.True(response.IsSuccessStatusCode, "Gateway should succeed after retrying throttled upstream responses.");
Assert.Equal(1, tokenClient.RequestCount);
Assert.Equal(3, recordingHandler.RequestCount);
}
finally
{
SystemClock.SleepAsync = originalSleep;
}
Assert.Equal(new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4) }, recordedDelays);
}
private static HttpResponseMessage CreateThrottleResponse()
{
var problem = new ProblemDetails
{
Title = "Too many requests",
Detail = "Slow down.",
Status = StatusCodes.Status429TooManyRequests
};
var response = new HttpResponseMessage((HttpStatusCode)StatusCodes.Status429TooManyRequests)
{
Content = JsonContent.Create(problem)
};
response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromMilliseconds(10));
return response;
}
private static string GetTag(ReadOnlySpan<KeyValuePair<string, object?>> tags, string key)
{
foreach (var tag in tags)
{
if (string.Equals(tag.Key, key, StringComparison.Ordinal))
{
return tag.Value?.ToString() ?? string.Empty;
}
}
return string.Empty;
}
private sealed class PolicyGatewayWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
{
var settings = new Dictionary<string, string?>
{
["PolicyGateway:Telemetry:MinimumLogLevel"] = "Warning",
["PolicyGateway:ResourceServer:Authority"] = "https://authority.test",
["PolicyGateway:ResourceServer:RequireHttpsMetadata"] = "false",
["PolicyGateway:ResourceServer:BypassNetworks:0"] = "127.0.0.1/32",
["PolicyGateway:ResourceServer:BypassNetworks:1"] = "::1/128",
["PolicyGateway:PolicyEngine:BaseAddress"] = "https://policy-engine.test/",
["PolicyGateway:PolicyEngine:ClientCredentials:Enabled"] = "true",
["PolicyGateway:PolicyEngine:ClientCredentials:ClientId"] = "policy-gateway",
["PolicyGateway:PolicyEngine:ClientCredentials:ClientSecret"] = "secret",
["PolicyGateway:PolicyEngine:ClientCredentials:Scopes:0"] = "policy:activate",
["PolicyGateway:PolicyEngine:Dpop:Enabled"] = "false"
};
configurationBuilder.AddInMemoryCollection(settings);
});
builder.ConfigureServices(services =>
{
services.RemoveAll<IStellaOpsTokenClient>();
services.AddSingleton<StubTokenClient>();
services.AddSingleton<IStellaOpsTokenClient>(sp => sp.GetRequiredService<StubTokenClient>());
services.RemoveAll<PolicyEngineClient>();
services.RemoveAll<IPolicyEngineClient>();
services.AddSingleton<RecordingPolicyEngineHandler>();
services.AddHttpClient<IPolicyEngineClient, PolicyEngineClient>()
.ConfigureHttpClient(client =>
{
client.BaseAddress = new Uri("https://policy-engine.test/");
})
.ConfigurePrimaryHttpMessageHandler(sp => sp.GetRequiredService<RecordingPolicyEngineHandler>());
services.AddSingleton<IStartupFilter>(new RemoteIpStartupFilter());
services.PostConfigure<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme, options =>
{
options.RequireHttpsMetadata = false;
options.Configuration = new OpenIdConnectConfiguration
{
Issuer = "https://authority.test",
TokenEndpoint = "https://authority.test/token"
};
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateIssuerSigningKey = false,
SignatureValidator = (token, parameters) => new JsonWebToken(token)
};
options.BackchannelHttpHandler = new NoOpBackchannelHandler();
});
});
}
}
private sealed class RemoteIpStartupFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
return app =>
{
app.Use(async (context, innerNext) =>
{
context.Connection.RemoteIpAddress ??= IPAddress.Loopback;
await innerNext().ConfigureAwait(false);
});
next(app);
};
}
}
private sealed class RecordingPolicyEngineHandler : HttpMessageHandler
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
public HttpRequestMessage? LastRequest { get; private set; }
public int RequestCount { get; private set; }
private Func<HttpRequestMessage, HttpResponseMessage>? responseFactory;
private Queue<HttpResponseMessage>? responseQueue;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
RequestCount++;
if (responseQueue is { Count: > 0 })
{
return Task.FromResult(responseQueue.Dequeue());
}
var response = responseFactory is not null
? responseFactory(request)
: CreateSuccessResponse();
return Task.FromResult(response);
}
public void Reset()
{
LastRequest = null;
RequestCount = 0;
responseFactory = null;
responseQueue?.Clear();
responseQueue = null;
}
public void SetResponseFactory(Func<HttpRequestMessage, HttpResponseMessage>? factory)
{
responseFactory = factory;
}
public void SetResponseSequence(IEnumerable<HttpResponseMessage> responses)
{
responseQueue = new Queue<HttpResponseMessage>(responses ?? Array.Empty<HttpResponseMessage>());
}
public static HttpResponseMessage CreateSuccessResponse()
{
var now = DateTimeOffset.UtcNow;
var payload = new PolicyRevisionActivationDto(
"activated",
new PolicyRevisionDto(
5,
"activated",
false,
now,
now,
Array.Empty<PolicyActivationApprovalDto>()));
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(payload, options: SerializerOptions)
};
}
}
private sealed class NoOpBackchannelHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
private sealed class StubTokenClient : IStellaOpsTokenClient
{
public int RequestCount { get; private set; }
public void Reset()
{
RequestCount = 0;
}
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
RequestCount++;
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(5);
return Task.FromResult(new StellaOpsTokenResult("service-token", "Bearer", expiresAt, Array.Empty<string>()));
}
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,212 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Net;
using System.Net.Http;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Auth.Client;
using StellaOps.Policy.Gateway.Clients;
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
using Xunit;
namespace StellaOps.Policy.Gateway.Tests;
public class PolicyEngineClientTests
{
[Fact]
public async Task ActivateRevision_UsesServiceTokenWhenForwardingContextMissing()
{
var options = CreateGatewayOptions();
options.PolicyEngine.ClientCredentials.Enabled = true;
options.PolicyEngine.ClientCredentials.ClientId = "policy-gateway";
options.PolicyEngine.ClientCredentials.ClientSecret = "secret";
options.PolicyEngine.ClientCredentials.Scopes.Clear();
options.PolicyEngine.ClientCredentials.Scopes.Add("policy:activate");
options.PolicyEngine.BaseAddress = "https://policy-engine.test/";
var optionsMonitor = new TestOptionsMonitor(options);
var tokenClient = new StubTokenClient();
var dpopGenerator = new PolicyGatewayDpopProofGenerator(new StubHostEnvironment(), optionsMonitor, TimeProvider.System, NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
var tokenProvider = new PolicyEngineTokenProvider(tokenClient, optionsMonitor, dpopGenerator, TimeProvider.System, NullLogger<PolicyEngineTokenProvider>.Instance);
using var recordingHandler = new RecordingHandler();
using var httpClient = new HttpClient(recordingHandler)
{
BaseAddress = new Uri(options.PolicyEngine.BaseAddress)
};
var client = new PolicyEngineClient(httpClient, Microsoft.Extensions.Options.Options.Create(options), tokenProvider, NullLogger<PolicyEngineClient>.Instance);
var request = new ActivatePolicyRevisionRequest("comment");
var result = await client.ActivatePolicyRevisionAsync(null, "pack-123", 7, request, CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.NotNull(recordingHandler.LastRequest);
var authorization = recordingHandler.LastRequest!.Headers.Authorization;
Assert.NotNull(authorization);
Assert.Equal("Bearer", authorization!.Scheme);
Assert.Equal("service-token", authorization.Parameter);
Assert.Equal(1, tokenClient.RequestCount);
}
[Fact]
public void Metrics_RecordActivation_EmitsExpectedTags()
{
using var metrics = new PolicyGatewayMetrics();
using var listener = new MeterListener();
var measurements = new List<(long Value, string Outcome, string Source)>();
var latencies = new List<(double Value, string Outcome, string Source)>();
listener.InstrumentPublished += (instrument, meterListener) =>
{
if (!string.Equals(instrument.Meter.Name, "StellaOps.Policy.Gateway", StringComparison.Ordinal))
{
return;
}
meterListener.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) =>
{
if (instrument.Name != "policy_gateway_activation_requests_total")
{
return;
}
measurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.SetMeasurementEventCallback<double>((instrument, value, tags, state) =>
{
if (instrument.Name != "policy_gateway_activation_latency_ms")
{
return;
}
latencies.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.Start();
metrics.RecordActivation("activated", "service", 42.5);
listener.Dispose();
Assert.Contains(measurements, entry => entry.Value == 1 && entry.Outcome == "activated" && entry.Source == "service");
Assert.Contains(latencies, entry => entry.Outcome == "activated" && entry.Source == "service" && entry.Value == 42.5);
}
private static string GetTag(ReadOnlySpan<KeyValuePair<string, object?>> tags, string key)
{
foreach (var tag in tags)
{
if (string.Equals(tag.Key, key, StringComparison.Ordinal))
{
return tag.Value?.ToString() ?? string.Empty;
}
}
return string.Empty;
}
private static PolicyGatewayOptions CreateGatewayOptions()
{
return new PolicyGatewayOptions
{
PolicyEngine =
{
BaseAddress = "https://policy-engine.test/"
}
};
}
private sealed class TestOptionsMonitor : IOptionsMonitor<PolicyGatewayOptions>
{
public TestOptionsMonitor(PolicyGatewayOptions current)
{
CurrentValue = current;
}
public PolicyGatewayOptions CurrentValue { get; }
public PolicyGatewayOptions Get(string? name) => CurrentValue;
public IDisposable OnChange(Action<PolicyGatewayOptions, string?> listener) => EmptyDisposable.Instance;
private sealed class EmptyDisposable : IDisposable
{
public static readonly EmptyDisposable Instance = new();
public void Dispose()
{
}
}
}
private sealed class StubTokenClient : IStellaOpsTokenClient
{
public int RequestCount { get; private set; }
public IReadOnlyDictionary<string, string>? LastAdditionalParameters { get; private set; }
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
RequestCount++;
LastAdditionalParameters = additionalParameters;
return Task.FromResult(new StellaOpsTokenResult("service-token", "Bearer", DateTimeOffset.UtcNow.AddMinutes(5), Array.Empty<string>()));
}
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
}
private sealed class RecordingHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
var payload = JsonSerializer.Serialize(new PolicyRevisionActivationDto("activated", new PolicyRevisionDto(7, "Activated", false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, Array.Empty<PolicyActivationApprovalDto>())));
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json")
};
return Task.FromResult(response);
}
}
private sealed class StubHostEnvironment : IHostEnvironment
{
public string EnvironmentName { get; set; } = "Development";
public string ApplicationName { get; set; } = "PolicyGatewayTests";
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
}
}

View File

@@ -0,0 +1,167 @@
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
using Xunit;
namespace StellaOps.Policy.Gateway.Tests;
public sealed class PolicyGatewayDpopProofGeneratorTests
{
[Fact]
public void CreateProof_Throws_WhenDpopDisabled()
{
var options = CreateGatewayOptions();
options.PolicyEngine.Dpop.Enabled = false;
using var generator = new PolicyGatewayDpopProofGenerator(
new StubHostEnvironment(AppContext.BaseDirectory),
new TestOptionsMonitor(options),
TimeProvider.System,
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
var exception = Assert.Throws<InvalidOperationException>(() =>
generator.CreateProof(HttpMethod.Get, new Uri("https://policy-engine.example/api"), null));
Assert.Equal("DPoP proof requested while DPoP is disabled.", exception.Message);
}
[Fact]
public void CreateProof_Throws_WhenKeyFileMissing()
{
var tempRoot = Directory.CreateTempSubdirectory();
try
{
var options = CreateGatewayOptions();
options.PolicyEngine.Dpop.Enabled = true;
options.PolicyEngine.Dpop.KeyPath = "missing-key.pem";
using var generator = new PolicyGatewayDpopProofGenerator(
new StubHostEnvironment(tempRoot.FullName),
new TestOptionsMonitor(options),
TimeProvider.System,
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
var exception = Assert.Throws<FileNotFoundException>(() =>
generator.CreateProof(HttpMethod.Post, new Uri("https://policy-engine.example/token"), null));
Assert.Contains("missing-key.pem", exception.FileName, StringComparison.Ordinal);
}
finally
{
tempRoot.Delete(recursive: true);
}
}
[Fact]
public void CreateProof_UsesConfiguredAlgorithmAndEmbedsTokenHash()
{
var tempRoot = Directory.CreateTempSubdirectory();
try
{
var keyPath = CreateEcKey(tempRoot, ECCurve.NamedCurves.nistP384);
var options = CreateGatewayOptions();
options.PolicyEngine.Dpop.Enabled = true;
options.PolicyEngine.Dpop.KeyPath = keyPath;
options.PolicyEngine.Dpop.Algorithm = "ES384";
using var generator = new PolicyGatewayDpopProofGenerator(
new StubHostEnvironment(tempRoot.FullName),
new TestOptionsMonitor(options),
TimeProvider.System,
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
const string accessToken = "sample-access-token";
var proof = generator.CreateProof(HttpMethod.Delete, new Uri("https://policy-engine.example/api/resource"), accessToken);
var token = new JwtSecurityTokenHandler().ReadJwtToken(proof);
Assert.Equal("dpop+jwt", token.Header.Typ);
Assert.Equal("ES384", token.Header.Alg);
Assert.Equal("DELETE", token.Payload.TryGetValue("htm", out var method) ? method?.ToString() : null);
Assert.Equal("https://policy-engine.example/api/resource", token.Payload.TryGetValue("htu", out var uri) ? uri?.ToString() : null);
Assert.True(token.Payload.TryGetValue("iat", out var issuedAt));
Assert.True(long.TryParse(Convert.ToString(issuedAt, CultureInfo.InvariantCulture), out var epoch));
Assert.True(epoch > 0);
Assert.True(token.Payload.TryGetValue("jti", out var jti));
Assert.False(string.IsNullOrWhiteSpace(Convert.ToString(jti, CultureInfo.InvariantCulture)));
Assert.True(token.Payload.TryGetValue("ath", out var ath));
var expectedHash = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(accessToken)));
Assert.Equal(expectedHash, ath?.ToString());
}
finally
{
tempRoot.Delete(recursive: true);
}
}
private static PolicyGatewayOptions CreateGatewayOptions()
{
return new PolicyGatewayOptions
{
PolicyEngine =
{
BaseAddress = "https://policy-engine.example"
}
};
}
private static string CreateEcKey(DirectoryInfo directory, ECCurve curve)
{
using var ecdsa = ECDsa.Create(curve);
var privateKey = ecdsa.ExportPkcs8PrivateKey();
var pem = PemEncoding.Write("PRIVATE KEY", privateKey);
var path = Path.Combine(directory.FullName, "policy-gateway-dpop.pem");
File.WriteAllText(path, pem);
return path;
}
private sealed class StubHostEnvironment : IHostEnvironment
{
public StubHostEnvironment(string contentRootPath)
{
ContentRootPath = contentRootPath;
}
public string ApplicationName { get; set; } = "PolicyGatewayTests";
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
public string ContentRootPath { get; set; }
public string EnvironmentName { get; set; } = Environments.Development;
}
private sealed class TestOptionsMonitor : IOptionsMonitor<PolicyGatewayOptions>
{
public TestOptionsMonitor(PolicyGatewayOptions current)
{
CurrentValue = current;
}
public PolicyGatewayOptions CurrentValue { get; }
public PolicyGatewayOptions Get(string? name) => CurrentValue;
public IDisposable OnChange(Action<PolicyGatewayOptions, string?> listener) => EmptyDisposable.Instance;
private sealed class EmptyDisposable : IDisposable
{
public static readonly EmptyDisposable Instance = new();
public void Dispose()
{
}
}
}
}

View File

@@ -0,0 +1,12 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Policy.Gateway/StellaOps.Policy.Gateway.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,157 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace StellaOps.Policy.Tests;
public sealed class PolicyBinderTests
{
[Fact]
public void Bind_ValidYaml_ReturnsSuccess()
{
const string yaml = """
version: "1.0"
rules:
- name: Block Critical
severity: [Critical]
sources: [NVD]
action: block
""";
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
Assert.True(result.Success);
Assert.Equal("1.0", result.Document.Version);
Assert.Single(result.Document.Rules);
Assert.Empty(result.Issues);
}
[Fact]
public void Bind_ExceptionsConfigured_ParsesDefinitions()
{
const string yaml = """
version: "1.0"
exceptions:
effects:
- id: suppress-temp
name: Temporary Suppress
effect: suppress
routingTemplate: secops
maxDurationDays: 30
- id: downgrade-ops
name: Downgrade To Low
effect: downgrade
downgradeSeverity: Low
routingTemplates:
- id: secops
authorityRouteId: route-secops
requireMfa: true
rules:
- name: Allow
action: ignore
""";
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
Assert.True(result.Success);
var effects = result.Document.Exceptions.Effects;
Assert.Equal(2, effects.Length);
var suppress = effects.Single(effect => effect.Id == "suppress-temp");
Assert.Equal(PolicyExceptionEffectType.Suppress, suppress.Effect);
Assert.Equal("Temporary Suppress", suppress.Name);
Assert.Equal("secops", suppress.RoutingTemplate);
Assert.Equal(30, suppress.MaxDurationDays);
var downgrade = effects.Single(effect => effect.Id == "downgrade-ops");
Assert.Equal(PolicyExceptionEffectType.Downgrade, downgrade.Effect);
Assert.Equal("Downgrade To Low", downgrade.Name);
Assert.Equal(PolicySeverity.Low, downgrade.DowngradeSeverity);
var routing = result.Document.Exceptions.RoutingTemplates;
Assert.Single(routing);
Assert.Equal("secops", routing[0].Id);
Assert.Equal("route-secops", routing[0].AuthorityRouteId);
Assert.True(routing[0].RequireMfa);
}
[Fact]
public void Bind_ExceptionDowngradeMissingSeverity_ReturnsError()
{
const string yaml = """
version: "1.0"
exceptions:
effects:
- id: downgrade-invalid
effect: downgrade
routingTemplates: []
rules:
- name: Allow
action: ignore
""";
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
Assert.False(result.Success);
Assert.Contains(result.Issues, issue => issue.Code == "policy.exceptions.effect.downgrade.missingSeverity");
}
[Fact]
public void Bind_InvalidSeverity_ReturnsError()
{
const string yaml = """
version: "1.0"
rules:
- name: Invalid Severity
severity: [Nope]
action: block
""";
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
Assert.False(result.Success);
Assert.Contains(result.Issues, issue => issue.Code == "policy.severity.invalid");
}
[Fact]
public async Task Cli_StrictMode_FailsOnWarnings()
{
const string yaml = """
version: "1.0"
rules:
- name: Quiet Warning
sources: ["", "NVD"]
action: ignore
""";
var path = Path.Combine(Path.GetTempPath(), $"policy-{Guid.NewGuid():N}.yaml");
await File.WriteAllTextAsync(path, yaml);
try
{
using var output = new StringWriter();
using var error = new StringWriter();
var cli = new PolicyValidationCli(output, error);
var options = new PolicyValidationCliOptions
{
Inputs = new[] { path },
Strict = true,
};
var exitCode = await cli.RunAsync(options, CancellationToken.None);
Assert.Equal(2, exitCode);
Assert.Contains("WARNING", output.ToString());
}
finally
{
if (File.Exists(path))
{
File.Delete(path);
}
}
}
}

View File

@@ -0,0 +1,136 @@
using System.Collections.Immutable;
using Xunit;
namespace StellaOps.Policy.Tests;
public sealed class PolicyEvaluationTests
{
[Fact]
public void EvaluateFinding_AppliesTrustAndReachabilityWeights()
{
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
var rule = PolicyRule.Create(
"BlockMedium",
action,
ImmutableArray.Create(PolicySeverity.Medium),
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
PolicyRuleMatchCriteria.Empty,
expires: null,
justification: null);
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty,
PolicyExceptionConfiguration.Empty);
var config = PolicyScoringConfig.Default;
var finding = PolicyFinding.Create(
"finding-medium",
PolicySeverity.Medium,
source: "community",
tags: ImmutableArray.Create("reachability:indirect"));
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding);
Assert.Equal(PolicyVerdictStatus.Blocked, verdict.Status);
Assert.Equal(19.5, verdict.Score, 3);
var inputs = verdict.GetInputs();
Assert.Equal(50, inputs["severityWeight"]);
Assert.Equal(0.65, inputs["trustWeight"], 3);
Assert.Equal(0.6, inputs["reachabilityWeight"], 3);
Assert.Equal(19.5, inputs["baseScore"], 3);
}
[Fact]
public void EvaluateFinding_QuietWithRequireVexAppliesQuietPenalty()
{
var ignoreOptions = new PolicyIgnoreOptions(null, null);
var requireVexOptions = new PolicyRequireVexOptions(
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty);
var action = new PolicyAction(PolicyActionType.Ignore, ignoreOptions, null, requireVexOptions, true);
var rule = PolicyRule.Create(
"QuietIgnore",
action,
ImmutableArray.Create(PolicySeverity.Critical),
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
PolicyRuleMatchCriteria.Empty,
expires: null,
justification: null);
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty,
PolicyExceptionConfiguration.Empty);
var config = PolicyScoringConfig.Default;
var finding = PolicyFinding.Create(
"finding-critical",
PolicySeverity.Critical,
tags: ImmutableArray.Create("reachability:entrypoint"));
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding);
Assert.Equal(PolicyVerdictStatus.Ignored, verdict.Status);
Assert.True(verdict.Quiet);
Assert.Equal("QuietIgnore", verdict.QuietedBy);
Assert.Equal(10, verdict.Score, 3);
var inputs = verdict.GetInputs();
Assert.Equal(90, inputs["baseScore"], 3);
Assert.Equal(config.IgnorePenalty, inputs["ignorePenalty"]);
Assert.Equal(config.QuietPenalty, inputs["quietPenalty"]);
}
[Fact]
public void EvaluateFinding_UnknownSeverityComputesConfidence()
{
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
var rule = PolicyRule.Create(
"BlockUnknown",
action,
ImmutableArray.Create(PolicySeverity.Unknown),
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
PolicyRuleMatchCriteria.Empty,
expires: null,
justification: null);
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty,
PolicyExceptionConfiguration.Empty);
var config = PolicyScoringConfig.Default;
var finding = PolicyFinding.Create(
"finding-unknown",
PolicySeverity.Unknown,
tags: ImmutableArray.Create("reachability:unknown", "unknown-age-days:5"));
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding);
Assert.Equal(PolicyVerdictStatus.Blocked, verdict.Status);
Assert.Equal(30, verdict.Score, 3); // 60 * 1 * 0.5
Assert.Equal(0.55, verdict.UnknownConfidence ?? 0, 3);
Assert.Equal("medium", verdict.ConfidenceBand);
Assert.Equal(5, verdict.UnknownAgeDays ?? 0, 3);
var inputs = verdict.GetInputs();
Assert.Equal(0.55, inputs["unknownConfidence"], 3);
Assert.Equal(5, inputs["unknownAgeDays"], 3);
}
}

View File

@@ -0,0 +1,185 @@
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Policy.Tests;
public sealed class PolicyPreviewServiceTests
{
private readonly ITestOutputHelper _output;
public PolicyPreviewServiceTests(ITestOutputHelper output)
{
_output = output ?? throw new ArgumentNullException(nameof(output));
}
[Fact]
public async Task PreviewAsync_ComputesDiffs_ForBlockingRule()
{
const string yaml = """
version: "1.0"
rules:
- name: Block Critical
severity: [Critical]
action: block
""";
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var timeProvider = new FakeTimeProvider();
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, null), CancellationToken.None);
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
var findings = ImmutableArray.Create(
PolicyFinding.Create("finding-1", PolicySeverity.Critical, environment: "prod", source: "NVD"),
PolicyFinding.Create("finding-2", PolicySeverity.Low));
var baseline = ImmutableArray.Create(
new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass),
new PolicyVerdict("finding-2", PolicyVerdictStatus.Pass));
var response = await service.PreviewAsync(new PolicyPreviewRequest(
"sha256:abc",
findings,
baseline),
CancellationToken.None);
Assert.True(response.Success);
Assert.Equal(1, response.ChangedCount);
var diff1 = Assert.Single(response.Diffs.Where(diff => diff.Projected.FindingId == "finding-1"));
Assert.Equal(PolicyVerdictStatus.Pass, diff1.Baseline.Status);
Assert.Equal(PolicyVerdictStatus.Blocked, diff1.Projected.Status);
Assert.Equal("Block Critical", diff1.Projected.RuleName);
Assert.True(diff1.Projected.Score > 0);
Assert.Equal(PolicyScoringConfig.Default.Version, diff1.Projected.ConfigVersion);
Assert.Equal(PolicyVerdictStatus.Pass, response.Diffs.First(diff => diff.Projected.FindingId == "finding-2").Projected.Status);
}
[Fact]
public async Task PreviewAsync_UsesProposedPolicy_WhenProvided()
{
const string yaml = """
version: "1.0"
rules:
- name: Ignore Dev
environments: [dev]
action:
type: ignore
justification: dev waiver
""";
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
var findings = ImmutableArray.Create(
PolicyFinding.Create("finding-1", PolicySeverity.Medium, environment: "dev"));
var baseline = ImmutableArray.Create(new PolicyVerdict("finding-1", PolicyVerdictStatus.Blocked));
var response = await service.PreviewAsync(new PolicyPreviewRequest(
"sha256:def",
findings,
baseline,
SnapshotOverride: null,
ProposedPolicy: new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, "dev override")),
CancellationToken.None);
Assert.True(response.Success);
var diff = Assert.Single(response.Diffs);
Assert.Equal(PolicyVerdictStatus.Blocked, diff.Baseline.Status);
Assert.Equal(PolicyVerdictStatus.Ignored, diff.Projected.Status);
Assert.Equal("Ignore Dev", diff.Projected.RuleName);
Assert.True(diff.Projected.Score >= 0);
Assert.Equal(1, response.ChangedCount);
}
[Fact]
public async Task PreviewAsync_ReturnsIssues_WhenPolicyInvalid()
{
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
const string invalid = "version: 1.0";
var request = new PolicyPreviewRequest(
"sha256:ghi",
ImmutableArray<PolicyFinding>.Empty,
ImmutableArray<PolicyVerdict>.Empty,
SnapshotOverride: null,
ProposedPolicy: new PolicySnapshotContent(invalid, PolicyDocumentFormat.Yaml, null, null, null));
var response = await service.PreviewAsync(request, CancellationToken.None);
Assert.False(response.Success);
Assert.NotEmpty(response.Issues);
}
[Fact]
public async Task PreviewAsync_QuietWithoutVexDowngradesToWarn()
{
const string yaml = """
version: "1.0"
rules:
- name: Quiet Without VEX
severity: [Low]
quiet: true
action:
type: ignore
""";
var binding = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
if (!binding.Success)
{
foreach (var issue in binding.Issues)
{
_output.WriteLine($"{issue.Severity} {issue.Code} {issue.Path} :: {issue.Message}");
}
var parseMethod = typeof(PolicyBinder).GetMethod("ParseToNode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
var node = (System.Text.Json.Nodes.JsonNode?)parseMethod?.Invoke(null, new object[] { yaml, PolicyDocumentFormat.Yaml });
_output.WriteLine(node?.ToJsonString() ?? "<null>");
}
Assert.True(binding.Success);
Assert.Empty(binding.Issues);
Assert.False(binding.Document.Rules[0].Metadata.ContainsKey("quiet"));
Assert.True(binding.Document.Rules[0].Action.Quiet);
var store = new PolicySnapshotStore(new InMemoryPolicySnapshotRepository(), new InMemoryPolicyAuditRepository(), TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, "quiet test"), CancellationToken.None);
var snapshot = await store.GetLatestAsync();
Assert.NotNull(snapshot);
Assert.True(snapshot!.Document.Rules[0].Action.Quiet);
Assert.Null(snapshot.Document.Rules[0].Action.RequireVex);
Assert.Equal(PolicyActionType.Ignore, snapshot.Document.Rules[0].Action.Type);
var manualVerdict = PolicyEvaluation.EvaluateFinding(snapshot.Document, snapshot.ScoringConfig, PolicyFinding.Create("finding-quiet", PolicySeverity.Low));
Assert.Equal(PolicyVerdictStatus.Warned, manualVerdict.Status);
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
var findings = ImmutableArray.Create(PolicyFinding.Create("finding-quiet", PolicySeverity.Low));
var baseline = ImmutableArray<PolicyVerdict>.Empty;
var response = await service.PreviewAsync(new PolicyPreviewRequest(
"sha256:quiet",
findings,
baseline),
CancellationToken.None);
Assert.True(response.Success);
var verdict = Assert.Single(response.Diffs).Projected;
Assert.Equal(PolicyVerdictStatus.Warned, verdict.Status);
Assert.Contains("requireVex", verdict.Notes, System.StringComparison.OrdinalIgnoreCase);
Assert.True(verdict.Score >= 0);
}
}

View File

@@ -0,0 +1,66 @@
using System;
using System.IO;
using Xunit;
namespace StellaOps.Policy.Tests;
public sealed class PolicyScoringConfigTests
{
[Fact]
public void LoadDefaultReturnsConfig()
{
var config = PolicyScoringConfigBinder.LoadDefault();
Assert.NotNull(config);
Assert.Equal("1.0", config.Version);
Assert.NotEmpty(config.SeverityWeights);
Assert.True(config.SeverityWeights.ContainsKey(PolicySeverity.Critical));
Assert.True(config.QuietPenalty > 0);
Assert.NotEmpty(config.ReachabilityBuckets);
Assert.Contains("entrypoint", config.ReachabilityBuckets.Keys);
Assert.False(config.UnknownConfidence.Bands.IsDefaultOrEmpty);
Assert.Equal("high", config.UnknownConfidence.Bands[0].Name);
}
[Fact]
public void BindRejectsEmptyContent()
{
var result = PolicyScoringConfigBinder.Bind(string.Empty, PolicyDocumentFormat.Json);
Assert.False(result.Success);
Assert.NotEmpty(result.Issues);
}
[Fact]
public void BindRejectsInvalidSchema()
{
const string json = """
{
"version": "1.0",
"severityWeights": {
"Critical": 90.0
}
}
""";
var result = PolicyScoringConfigBinder.Bind(json, PolicyDocumentFormat.Json);
Assert.False(result.Success);
Assert.Contains(result.Issues, issue => issue.Code.StartsWith("scoring.schema", StringComparison.OrdinalIgnoreCase));
Assert.Null(result.Config);
}
[Fact]
public void DefaultResourceDigestMatchesGolden()
{
var assembly = typeof(PolicyScoringConfig).Assembly;
using var stream = assembly.GetManifestResourceStream("StellaOps.Policy.Schemas.policy-scoring-default.json")
?? throw new InvalidOperationException("Unable to locate embedded scoring default resource.");
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var binding = PolicyScoringConfigBinder.Bind(json, PolicyDocumentFormat.Json);
Assert.True(binding.Success);
Assert.NotNull(binding.Config);
var digest = PolicyScoringConfigDigest.Compute(binding.Config!);
Assert.Equal("5ef2e43a112cb00753beb7811dd2e1720f2385e2289d0fb6abcf7bbbb8cda2d2", digest);
}
}

View File

@@ -0,0 +1,94 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Xunit;
namespace StellaOps.Policy.Tests;
public sealed class PolicySnapshotStoreTests
{
private const string BasePolicyYaml = """
version: "1.0"
rules:
- name: Block Critical
severity: [Critical]
action: block
""";
[Fact]
public async Task SaveAsync_CreatesNewSnapshotAndAuditEntry()
{
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero));
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null);
var result = await store.SaveAsync(content, CancellationToken.None);
Assert.True(result.Success);
Assert.True(result.Created);
Assert.NotNull(result.Snapshot);
Assert.Equal("rev-1", result.Snapshot!.RevisionId);
Assert.Equal(result.Digest, result.Snapshot.Digest);
Assert.Equal(timeProvider.GetUtcNow(), result.Snapshot.CreatedAt);
Assert.Equal(PolicyScoringConfig.Default.Version, result.Snapshot.ScoringConfig.Version);
var latest = await store.GetLatestAsync();
Assert.Equal(result.Snapshot, latest);
var audits = await auditRepo.ListAsync(10);
Assert.Single(audits);
Assert.Equal(result.Digest, audits[0].Digest);
Assert.Equal("snapshot.created", audits[0].Action);
Assert.Equal("rev-1", audits[0].RevisionId);
}
[Fact]
public async Task SaveAsync_DoesNotCreateNewRevisionWhenDigestUnchanged()
{
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero));
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null);
var first = await store.SaveAsync(content, CancellationToken.None);
Assert.True(first.Created);
timeProvider.Advance(TimeSpan.FromHours(1));
var second = await store.SaveAsync(content, CancellationToken.None);
Assert.True(second.Success);
Assert.False(second.Created);
Assert.Equal(first.Digest, second.Digest);
Assert.Equal("rev-1", second.Snapshot!.RevisionId);
Assert.Equal(PolicyScoringConfig.Default.Version, second.Snapshot.ScoringConfig.Version);
var audits = await auditRepo.ListAsync(10);
Assert.Single(audits);
}
[Fact]
public async Task SaveAsync_ReturnsFailureWhenValidationFails()
{
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
const string invalidYaml = "version: '1.0'\nrules: []";
var content = new PolicySnapshotContent(invalidYaml, PolicyDocumentFormat.Yaml, null, null, null);
var result = await store.SaveAsync(content, CancellationToken.None);
Assert.False(result.Success);
Assert.False(result.Created);
Assert.Null(result.Snapshot);
var audits = await auditRepo.ListAsync(5);
Assert.Empty(audits);
}
}

View File

@@ -0,0 +1,14 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
</ItemGroup>
</Project>