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