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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
using StellaOps.Policy.Gateway.Infrastructure;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Clients;
|
||||
|
||||
internal interface IPolicyEngineClient
|
||||
{
|
||||
Task<PolicyEngineResponse<IReadOnlyList<PolicyPackSummaryDto>>> ListPolicyPacksAsync(GatewayForwardingContext? forwardingContext, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyEngineResponse<PolicyPackDto>> CreatePolicyPackAsync(GatewayForwardingContext? forwardingContext, CreatePolicyPackRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyEngineResponse<PolicyRevisionDto>> CreatePolicyRevisionAsync(GatewayForwardingContext? forwardingContext, string packId, CreatePolicyRevisionRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyEngineResponse<PolicyRevisionActivationDto>> ActivatePolicyRevisionAsync(GatewayForwardingContext? forwardingContext, string packId, int version, ActivatePolicyRevisionRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
using StellaOps.Policy.Gateway.Infrastructure;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Clients;
|
||||
|
||||
internal sealed class PolicyEngineClient : IPolicyEngineClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly PolicyEngineTokenProvider tokenProvider;
|
||||
private readonly ILogger<PolicyEngineClient> logger;
|
||||
private readonly PolicyGatewayOptions options;
|
||||
|
||||
public PolicyEngineClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<PolicyGatewayOptions> options,
|
||||
PolicyEngineTokenProvider tokenProvider,
|
||||
ILogger<PolicyEngineClient> logger)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
this.options = options.Value ?? throw new InvalidOperationException("Policy Gateway options must be configured.");
|
||||
if (httpClient.BaseAddress is null)
|
||||
{
|
||||
httpClient.BaseAddress = this.options.PolicyEngine.BaseUri;
|
||||
}
|
||||
|
||||
httpClient.DefaultRequestHeaders.Accept.Clear();
|
||||
httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
}
|
||||
|
||||
public Task<PolicyEngineResponse<IReadOnlyList<PolicyPackSummaryDto>>> ListPolicyPacksAsync(
|
||||
GatewayForwardingContext? forwardingContext,
|
||||
CancellationToken cancellationToken)
|
||||
=> SendAsync<IReadOnlyList<PolicyPackSummaryDto>>(
|
||||
HttpMethod.Get,
|
||||
"api/policy/packs",
|
||||
forwardingContext,
|
||||
content: null,
|
||||
cancellationToken);
|
||||
|
||||
public Task<PolicyEngineResponse<PolicyPackDto>> CreatePolicyPackAsync(
|
||||
GatewayForwardingContext? forwardingContext,
|
||||
CreatePolicyPackRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
=> SendAsync<PolicyPackDto>(
|
||||
HttpMethod.Post,
|
||||
"api/policy/packs",
|
||||
forwardingContext,
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
public Task<PolicyEngineResponse<PolicyRevisionDto>> CreatePolicyRevisionAsync(
|
||||
GatewayForwardingContext? forwardingContext,
|
||||
string packId,
|
||||
CreatePolicyRevisionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
=> SendAsync<PolicyRevisionDto>(
|
||||
HttpMethod.Post,
|
||||
$"api/policy/packs/{Uri.EscapeDataString(packId)}/revisions",
|
||||
forwardingContext,
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
public Task<PolicyEngineResponse<PolicyRevisionActivationDto>> ActivatePolicyRevisionAsync(
|
||||
GatewayForwardingContext? forwardingContext,
|
||||
string packId,
|
||||
int version,
|
||||
ActivatePolicyRevisionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
=> SendAsync<PolicyRevisionActivationDto>(
|
||||
HttpMethod.Post,
|
||||
$"api/policy/packs/{Uri.EscapeDataString(packId)}/revisions/{version}:activate",
|
||||
forwardingContext,
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<PolicyEngineResponse<TSuccess>> SendAsync<TSuccess>(
|
||||
HttpMethod method,
|
||||
string relativeUri,
|
||||
GatewayForwardingContext? forwardingContext,
|
||||
object? content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var absoluteUri = httpClient.BaseAddress is not null
|
||||
? new Uri(httpClient.BaseAddress, relativeUri)
|
||||
: new Uri(relativeUri, UriKind.Absolute);
|
||||
|
||||
using var request = new HttpRequestMessage(method, absoluteUri);
|
||||
|
||||
if (forwardingContext is not null)
|
||||
{
|
||||
forwardingContext.Apply(request);
|
||||
}
|
||||
else
|
||||
{
|
||||
var serviceAuthorization = await tokenProvider.GetAuthorizationAsync(method, absoluteUri, cancellationToken).ConfigureAwait(false);
|
||||
if (serviceAuthorization is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Policy Engine request {Method} {Uri} lacks caller credentials and client credentials flow is disabled.",
|
||||
method,
|
||||
absoluteUri);
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = "Upstream authorization missing",
|
||||
Detail = "Caller did not present credentials and client credentials flow is disabled.",
|
||||
Status = StatusCodes.Status401Unauthorized
|
||||
};
|
||||
return PolicyEngineResponse<TSuccess>.Failure(HttpStatusCode.Unauthorized, problem);
|
||||
}
|
||||
|
||||
var authorization = serviceAuthorization.Value;
|
||||
authorization.Apply(request);
|
||||
}
|
||||
|
||||
if (content is not null)
|
||||
{
|
||||
request.Content = JsonContent.Create(content, options: SerializerOptions);
|
||||
}
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
if (response.Content is null || response.Content.Headers.ContentLength == 0)
|
||||
{
|
||||
return PolicyEngineResponse<TSuccess>.Success(response.StatusCode, value: default, location);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var successValue = await response.Content.ReadFromJsonAsync<TSuccess>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
return PolicyEngineResponse<TSuccess>.Success(response.StatusCode, successValue, location);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to deserialize Policy Engine response for {Path}.", relativeUri);
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = "Invalid upstream response",
|
||||
Detail = "Policy Engine returned an unexpected payload.",
|
||||
Status = StatusCodes.Status502BadGateway
|
||||
};
|
||||
return PolicyEngineResponse<TSuccess>.Failure(HttpStatusCode.BadGateway, problem);
|
||||
}
|
||||
}
|
||||
|
||||
var problemDetails = await ReadProblemDetailsAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
return PolicyEngineResponse<TSuccess>.Failure(response.StatusCode, problemDetails);
|
||||
}
|
||||
|
||||
private async Task<ProblemDetails?> ReadProblemDetailsAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.Content is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await response.Content.ReadFromJsonAsync<ProblemDetails>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Policy Engine returned non-ProblemDetails error response for {StatusCode}.", (int)response.StatusCode);
|
||||
return new ProblemDetails
|
||||
{
|
||||
Title = "Upstream error",
|
||||
Detail = $"Policy Engine responded with {(int)response.StatusCode} {response.ReasonPhrase}.",
|
||||
Status = (int)response.StatusCode
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Clients;
|
||||
|
||||
internal sealed class PolicyEngineResponse<TSuccess>
|
||||
{
|
||||
private PolicyEngineResponse(HttpStatusCode statusCode, TSuccess? value, ProblemDetails? problem, string? location)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
Value = value;
|
||||
Problem = problem;
|
||||
Location = location;
|
||||
}
|
||||
|
||||
public HttpStatusCode StatusCode { get; }
|
||||
|
||||
public TSuccess? Value { get; }
|
||||
|
||||
public ProblemDetails? Problem { get; }
|
||||
|
||||
public string? Location { get; }
|
||||
|
||||
public bool IsSuccess => Problem is null && StatusCode is >= HttpStatusCode.OK and < HttpStatusCode.MultipleChoices;
|
||||
|
||||
public static PolicyEngineResponse<TSuccess> Success(HttpStatusCode statusCode, TSuccess? value, string? location)
|
||||
=> new(statusCode, value, problem: null, location);
|
||||
|
||||
public static PolicyEngineResponse<TSuccess> Failure(HttpStatusCode statusCode, ProblemDetails? problem)
|
||||
=> new(statusCode, value: default, problem, location: null);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Clients;
|
||||
|
||||
internal static class PolicyEngineResponseExtensions
|
||||
{
|
||||
public static IResult ToMinimalResult<T>(this PolicyEngineResponse<T> response)
|
||||
{
|
||||
if (response is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(response));
|
||||
}
|
||||
|
||||
if (response.IsSuccess)
|
||||
{
|
||||
return CreateSuccessResult(response);
|
||||
}
|
||||
|
||||
return CreateErrorResult(response);
|
||||
}
|
||||
|
||||
private static IResult CreateSuccessResult<T>(PolicyEngineResponse<T> response)
|
||||
{
|
||||
var value = response.Value;
|
||||
switch (response.StatusCode)
|
||||
{
|
||||
case HttpStatusCode.Created:
|
||||
if (!string.IsNullOrWhiteSpace(response.Location))
|
||||
{
|
||||
return Results.Created(response.Location, value);
|
||||
}
|
||||
|
||||
return Results.Json(value, statusCode: StatusCodes.Status201Created);
|
||||
|
||||
case HttpStatusCode.Accepted:
|
||||
if (!string.IsNullOrWhiteSpace(response.Location))
|
||||
{
|
||||
return Results.Accepted(response.Location, value);
|
||||
}
|
||||
|
||||
return Results.Json(value, statusCode: StatusCodes.Status202Accepted);
|
||||
|
||||
case HttpStatusCode.NoContent:
|
||||
return Results.NoContent();
|
||||
|
||||
default:
|
||||
return Results.Json(value, statusCode: (int)response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult CreateErrorResult<T>(PolicyEngineResponse<T> response)
|
||||
{
|
||||
var problem = response.Problem;
|
||||
if (problem is null)
|
||||
{
|
||||
return Results.StatusCode((int)response.StatusCode);
|
||||
}
|
||||
|
||||
var statusCode = problem.Status ?? (int)response.StatusCode;
|
||||
return Results.Problem(
|
||||
title: problem.Title,
|
||||
detail: problem.Detail,
|
||||
type: problem.Type,
|
||||
instance: problem.Instance,
|
||||
statusCode: statusCode,
|
||||
extensions: problem.Extensions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Contracts;
|
||||
|
||||
public sealed record PolicyPackSummaryDto(
|
||||
string PackId,
|
||||
string? DisplayName,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<int> Versions);
|
||||
|
||||
public sealed record PolicyPackDto(
|
||||
string PackId,
|
||||
string? DisplayName,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<PolicyRevisionDto> Revisions);
|
||||
|
||||
public sealed record PolicyRevisionDto(
|
||||
int Version,
|
||||
string Status,
|
||||
bool RequiresTwoPersonApproval,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ActivatedAt,
|
||||
IReadOnlyList<PolicyActivationApprovalDto> Approvals);
|
||||
|
||||
public sealed record PolicyActivationApprovalDto(
|
||||
string ActorId,
|
||||
DateTimeOffset ApprovedAt,
|
||||
string? Comment);
|
||||
|
||||
public sealed record PolicyRevisionActivationDto(
|
||||
string Status,
|
||||
PolicyRevisionDto Revision);
|
||||
|
||||
public sealed record CreatePolicyPackRequest(
|
||||
[StringLength(200)] string? PackId,
|
||||
[StringLength(200)] string? DisplayName);
|
||||
|
||||
public sealed record CreatePolicyRevisionRequest(
|
||||
int? Version,
|
||||
bool RequiresTwoPersonApproval,
|
||||
string InitialStatus = "Approved");
|
||||
|
||||
public sealed record ActivatePolicyRevisionRequest(string? Comment);
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Infrastructure;
|
||||
|
||||
internal sealed record GatewayForwardingContext(string Authorization, string? Dpop, string? Tenant)
|
||||
{
|
||||
private static readonly string[] ForwardedHeaders =
|
||||
{
|
||||
"Authorization",
|
||||
"DPoP",
|
||||
"X-Stella-Tenant"
|
||||
};
|
||||
|
||||
public void Apply(HttpRequestMessage request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
request.Headers.TryAddWithoutValidation(ForwardedHeaders[0], Authorization);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Dpop))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(ForwardedHeaders[1], Dpop);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Tenant))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(ForwardedHeaders[2], Tenant);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryCreate(HttpContext context, out GatewayForwardingContext forwardingContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var authorization = context.Request.Headers.Authorization.ToString();
|
||||
if (string.IsNullOrWhiteSpace(authorization))
|
||||
{
|
||||
forwardingContext = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
var dpop = context.Request.Headers["DPoP"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(dpop))
|
||||
{
|
||||
dpop = null;
|
||||
}
|
||||
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
tenant = null;
|
||||
}
|
||||
|
||||
forwardingContext = new GatewayForwardingContext(authorization.Trim(), dpop, tenant);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Root configuration for the Policy Gateway host.
|
||||
/// </summary>
|
||||
public sealed class PolicyGatewayOptions
|
||||
{
|
||||
public const string SectionName = "PolicyGateway";
|
||||
|
||||
public PolicyGatewayTelemetryOptions Telemetry { get; } = new();
|
||||
|
||||
public PolicyGatewayResourceServerOptions ResourceServer { get; } = new();
|
||||
|
||||
public PolicyGatewayPolicyEngineOptions PolicyEngine { get; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
Telemetry.Validate();
|
||||
ResourceServer.Validate();
|
||||
PolicyEngine.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logging and telemetry configuration for the gateway.
|
||||
/// </summary>
|
||||
public sealed class PolicyGatewayTelemetryOptions
|
||||
{
|
||||
public LogLevel MinimumLogLevel { get; set; } = LogLevel.Information;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enum.IsDefined(typeof(LogLevel), MinimumLogLevel))
|
||||
{
|
||||
throw new InvalidOperationException("Unsupported log level configured for Policy Gateway telemetry.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JWT resource server configuration for incoming requests handled by the gateway.
|
||||
/// </summary>
|
||||
public sealed class PolicyGatewayResourceServerOptions
|
||||
{
|
||||
public string Authority { get; set; } = "https://authority.stella-ops.local";
|
||||
|
||||
public string? MetadataAddress { get; set; }
|
||||
= "https://authority.stella-ops.local/.well-known/openid-configuration";
|
||||
|
||||
public IList<string> Audiences { get; } = new List<string> { "api://policy-gateway" };
|
||||
|
||||
public IList<string> RequiredScopes { get; } = new List<string>
|
||||
{
|
||||
StellaOpsScopes.PolicyRead,
|
||||
StellaOpsScopes.PolicyAuthor,
|
||||
StellaOpsScopes.PolicyReview,
|
||||
StellaOpsScopes.PolicyApprove,
|
||||
StellaOpsScopes.PolicyOperate,
|
||||
StellaOpsScopes.PolicySimulate,
|
||||
StellaOpsScopes.PolicyRun,
|
||||
StellaOpsScopes.PolicyActivate
|
||||
};
|
||||
|
||||
public IList<string> RequiredTenants { get; } = new List<string>();
|
||||
|
||||
public IList<string> BypassNetworks { get; } = new List<string> { "127.0.0.1/32", "::1/128" };
|
||||
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
public int BackchannelTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public int TokenClockSkewSeconds { get; set; } = 60;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Authority))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway resource server configuration requires an Authority URL.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var authorityUri))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway resource server Authority URL must be absolute.");
|
||||
}
|
||||
|
||||
if (RequireHttpsMetadata &&
|
||||
!authorityUri.IsLoopback &&
|
||||
!string.Equals(authorityUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway resource server Authority URL must use HTTPS when metadata requires HTTPS.");
|
||||
}
|
||||
|
||||
if (BackchannelTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway resource server back-channel timeout must be greater than zero seconds.");
|
||||
}
|
||||
|
||||
if (TokenClockSkewSeconds < 0 || TokenClockSkewSeconds > 300)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway resource server token clock skew must be between 0 and 300 seconds.");
|
||||
}
|
||||
|
||||
NormalizeList(Audiences, toLower: false);
|
||||
NormalizeList(RequiredScopes, toLower: true);
|
||||
NormalizeList(RequiredTenants, toLower: true);
|
||||
NormalizeList(BypassNetworks, toLower: false);
|
||||
}
|
||||
|
||||
private static void NormalizeList(IList<string> values, bool toLower)
|
||||
{
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var unique = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var index = values.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var value = values[index];
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = value.Trim();
|
||||
if (toLower)
|
||||
{
|
||||
normalized = normalized.ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (!unique.Add(normalized))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
values[index] = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outbound Policy Engine configuration used by the gateway to forward requests.
|
||||
/// </summary>
|
||||
public sealed class PolicyGatewayPolicyEngineOptions
|
||||
{
|
||||
public string BaseAddress { get; set; } = "https://policy-engine.stella-ops.local";
|
||||
|
||||
public string Audience { get; set; } = "api://policy-engine";
|
||||
|
||||
public PolicyGatewayClientCredentialsOptions ClientCredentials { get; } = new();
|
||||
|
||||
public PolicyGatewayDpopOptions Dpop { get; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(BaseAddress))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway requires a Policy Engine base address.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(BaseAddress.Trim(), UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway Policy Engine base address must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (!string.Equals(baseUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && !baseUri.IsLoopback)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway Policy Engine base address must use HTTPS unless targeting loopback.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Audience))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway requires a Policy Engine audience value for client credential flows.");
|
||||
}
|
||||
|
||||
ClientCredentials.Validate();
|
||||
Dpop.Validate();
|
||||
}
|
||||
|
||||
public Uri BaseUri => new(BaseAddress, UriKind.Absolute);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client credential configuration for the gateway when calling the Policy Engine.
|
||||
/// </summary>
|
||||
public sealed class PolicyGatewayClientCredentialsOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string ClientId { get; set; } = "policy-gateway";
|
||||
|
||||
public string? ClientSecret { get; set; }
|
||||
= "change-me";
|
||||
|
||||
public IList<string> Scopes { get; } = new List<string>
|
||||
{
|
||||
StellaOpsScopes.PolicyRead,
|
||||
StellaOpsScopes.PolicyAuthor,
|
||||
StellaOpsScopes.PolicyReview,
|
||||
StellaOpsScopes.PolicyApprove,
|
||||
StellaOpsScopes.PolicyOperate,
|
||||
StellaOpsScopes.PolicySimulate,
|
||||
StellaOpsScopes.PolicyRun,
|
||||
StellaOpsScopes.PolicyActivate
|
||||
};
|
||||
|
||||
public int BackchannelTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ClientId))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway client credential configuration requires a client identifier when enabled.");
|
||||
}
|
||||
|
||||
if (Scopes.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway client credential configuration requires at least one scope when enabled.");
|
||||
}
|
||||
|
||||
var normalized = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var index = Scopes.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var scope = Scopes[index];
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
Scopes.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = scope.Trim().ToLowerInvariant();
|
||||
if (!normalized.Add(trimmed))
|
||||
{
|
||||
Scopes.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
Scopes[index] = trimmed;
|
||||
}
|
||||
|
||||
if (Scopes.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway client credential configuration requires at least one non-empty scope when enabled.");
|
||||
}
|
||||
|
||||
if (BackchannelTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway client credential back-channel timeout must be greater than zero seconds.");
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> NormalizedScopes => new ReadOnlyCollection<string>(Scopes);
|
||||
|
||||
public TimeSpan BackchannelTimeout => TimeSpan.FromSeconds(BackchannelTimeoutSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DPoP sender-constrained credential configuration for outbound Policy Engine calls.
|
||||
/// </summary>
|
||||
public sealed class PolicyGatewayDpopOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
public string KeyPath { get; set; } = string.Empty;
|
||||
|
||||
public string? KeyPassphrase { get; set; }
|
||||
= null;
|
||||
|
||||
public string Algorithm { get; set; } = "ES256";
|
||||
|
||||
public TimeSpan ProofLifetime { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
public TimeSpan ClockSkew { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(KeyPath))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway DPoP configuration requires a key path when enabled.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Algorithm))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway DPoP configuration requires an algorithm when enabled.");
|
||||
}
|
||||
|
||||
var normalizedAlgorithm = Algorithm.Trim().ToUpperInvariant();
|
||||
if (normalizedAlgorithm is not ("ES256" or "ES384"))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway DPoP configuration supports only ES256 or ES384 algorithms.");
|
||||
}
|
||||
|
||||
if (ProofLifetime <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway DPoP proof lifetime must be greater than zero.");
|
||||
}
|
||||
|
||||
if (ClockSkew < TimeSpan.Zero || ClockSkew > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Gateway DPoP clock skew must be between 0 seconds and 5 minutes.");
|
||||
}
|
||||
|
||||
Algorithm = normalizedAlgorithm;
|
||||
}
|
||||
}
|
||||
406
src/Policy/StellaOps.Policy.Gateway/Program.cs
Normal file
406
src/Policy/StellaOps.Policy.Gateway/Program.cs
Normal file
@@ -0,0 +1,406 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NetEscapades.Configuration.Yaml;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Policy.Gateway.Clients;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
using StellaOps.Policy.Gateway.Infrastructure;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using Polly;
|
||||
using Polly.Extensions.Http;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddJsonConsole();
|
||||
|
||||
builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "STELLAOPS_POLICY_GATEWAY_";
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
var contentRoot = builder.Environment.ContentRootPath;
|
||||
foreach (var relative in new[]
|
||||
{
|
||||
"../etc/policy-gateway.yaml",
|
||||
"../etc/policy-gateway.local.yaml",
|
||||
"policy-gateway.yaml",
|
||||
"policy-gateway.local.yaml"
|
||||
})
|
||||
{
|
||||
var path = Path.Combine(contentRoot, relative);
|
||||
configurationBuilder.AddYamlFile(path, optional: true);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyGatewayOptions>(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "STELLAOPS_POLICY_GATEWAY_";
|
||||
options.BindingSection = PolicyGatewayOptions.SectionName;
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
foreach (var relative in new[]
|
||||
{
|
||||
"../etc/policy-gateway.yaml",
|
||||
"../etc/policy-gateway.local.yaml",
|
||||
"policy-gateway.yaml",
|
||||
"policy-gateway.local.yaml"
|
||||
})
|
||||
{
|
||||
var path = Path.Combine(builder.Environment.ContentRootPath, relative);
|
||||
configurationBuilder.AddYamlFile(path, optional: true);
|
||||
}
|
||||
};
|
||||
options.PostBind = static (value, _) => value.Validate();
|
||||
});
|
||||
|
||||
builder.Configuration.AddConfiguration(bootstrap.Configuration);
|
||||
|
||||
builder.Logging.SetMinimumLevel(bootstrap.Options.Telemetry.MinimumLogLevel);
|
||||
|
||||
builder.Services.AddOptions<PolicyGatewayOptions>()
|
||||
.Bind(builder.Configuration.GetSection(PolicyGatewayOptions.SectionName))
|
||||
.Validate(options =>
|
||||
{
|
||||
try
|
||||
{
|
||||
options.Validate();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new OptionsValidationException(
|
||||
PolicyGatewayOptions.SectionName,
|
||||
typeof(PolicyGatewayOptions),
|
||||
new[] { ex.Message });
|
||||
}
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value);
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddAuthentication();
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddStellaOpsScopeHandler();
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: $"{PolicyGatewayOptions.SectionName}:ResourceServer");
|
||||
builder.Services.AddSingleton<PolicyGatewayMetrics>();
|
||||
builder.Services.AddSingleton<PolicyGatewayDpopProofGenerator>();
|
||||
builder.Services.AddSingleton<PolicyEngineTokenProvider>();
|
||||
builder.Services.AddTransient<PolicyGatewayDpopHandler>();
|
||||
|
||||
if (bootstrap.Options.PolicyEngine.ClientCredentials.Enabled)
|
||||
{
|
||||
builder.Services.AddOptions<StellaOpsAuthClientOptions>()
|
||||
.Configure(options =>
|
||||
{
|
||||
options.Authority = bootstrap.Options.ResourceServer.Authority;
|
||||
options.ClientId = bootstrap.Options.PolicyEngine.ClientCredentials.ClientId;
|
||||
options.ClientSecret = bootstrap.Options.PolicyEngine.ClientCredentials.ClientSecret;
|
||||
options.HttpTimeout = TimeSpan.FromSeconds(bootstrap.Options.PolicyEngine.ClientCredentials.BackchannelTimeoutSeconds);
|
||||
foreach (var scope in bootstrap.Options.PolicyEngine.ClientCredentials.Scopes)
|
||||
{
|
||||
options.DefaultScopes.Add(scope);
|
||||
}
|
||||
})
|
||||
.PostConfigure(static opt => opt.Validate());
|
||||
|
||||
builder.Services.TryAddSingleton<IStellaOpsTokenCache, InMemoryTokenCache>();
|
||||
|
||||
builder.Services.AddHttpClient<StellaOpsDiscoveryCache>((provider, client) =>
|
||||
{
|
||||
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
client.Timeout = authOptions.HttpTimeout;
|
||||
}).AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider));
|
||||
|
||||
builder.Services.AddHttpClient<StellaOpsJwksCache>((provider, client) =>
|
||||
{
|
||||
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
client.Timeout = authOptions.HttpTimeout;
|
||||
}).AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider));
|
||||
|
||||
builder.Services.AddHttpClient<IStellaOpsTokenClient, StellaOpsTokenClient>((provider, client) =>
|
||||
{
|
||||
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
client.Timeout = authOptions.HttpTimeout;
|
||||
})
|
||||
.AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider))
|
||||
.AddHttpMessageHandler<PolicyGatewayDpopHandler>();
|
||||
}
|
||||
|
||||
builder.Services.AddHttpClient<IPolicyEngineClient, PolicyEngineClient>((serviceProvider, client) =>
|
||||
{
|
||||
var gatewayOptions = serviceProvider.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value;
|
||||
client.BaseAddress = gatewayOptions.PolicyEngine.BaseUri;
|
||||
client.Timeout = TimeSpan.FromSeconds(gatewayOptions.PolicyEngine.ClientCredentials.BackchannelTimeoutSeconds);
|
||||
})
|
||||
.AddPolicyHandler(static (provider, _) => CreatePolicyEngineRetryPolicy(provider));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseExceptionHandler(static appBuilder => appBuilder.Run(async context =>
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Unexpected gateway error." });
|
||||
}));
|
||||
|
||||
app.UseStatusCodePages();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
|
||||
app.MapGet("/readyz", () => Results.Ok(new { status = "ready" }))
|
||||
.WithName("Readiness");
|
||||
|
||||
app.MapGet("/", () => Results.Redirect("/healthz"));
|
||||
|
||||
var policyPacks = app.MapGroup("/api/policy/packs")
|
||||
.WithTags("Policy Packs");
|
||||
|
||||
policyPacks.MapGet(string.Empty, async Task<IResult> (
|
||||
HttpContext context,
|
||||
IPolicyEngineClient client,
|
||||
PolicyEngineTokenProvider tokenProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
GatewayForwardingContext? forwardingContext = null;
|
||||
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
|
||||
{
|
||||
forwardingContext = callerContext;
|
||||
}
|
||||
else if (!tokenProvider.IsEnabled)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var response = await client.ListPolicyPacksAsync(forwardingContext, cancellationToken).ConfigureAwait(false);
|
||||
return response.ToMinimalResult();
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
|
||||
|
||||
policyPacks.MapPost(string.Empty, async Task<IResult> (
|
||||
HttpContext context,
|
||||
CreatePolicyPackRequest request,
|
||||
IPolicyEngineClient client,
|
||||
PolicyEngineTokenProvider tokenProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Request body required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
GatewayForwardingContext? forwardingContext = null;
|
||||
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
|
||||
{
|
||||
forwardingContext = callerContext;
|
||||
}
|
||||
else if (!tokenProvider.IsEnabled)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var response = await client.CreatePolicyPackAsync(forwardingContext, request, cancellationToken).ConfigureAwait(false);
|
||||
return response.ToMinimalResult();
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
|
||||
|
||||
policyPacks.MapPost("/{packId}/revisions", async Task<IResult> (
|
||||
HttpContext context,
|
||||
string packId,
|
||||
CreatePolicyRevisionRequest request,
|
||||
IPolicyEngineClient client,
|
||||
PolicyEngineTokenProvider tokenProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "packId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Request body required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
GatewayForwardingContext? forwardingContext = null;
|
||||
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
|
||||
{
|
||||
forwardingContext = callerContext;
|
||||
}
|
||||
else if (!tokenProvider.IsEnabled)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var response = await client.CreatePolicyRevisionAsync(forwardingContext, packId, request, cancellationToken).ConfigureAwait(false);
|
||||
return response.ToMinimalResult();
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
|
||||
|
||||
policyPacks.MapPost("/{packId}/revisions/{version:int}:activate", async Task<IResult> (
|
||||
HttpContext context,
|
||||
string packId,
|
||||
int version,
|
||||
ActivatePolicyRevisionRequest request,
|
||||
IPolicyEngineClient client,
|
||||
PolicyEngineTokenProvider tokenProvider,
|
||||
PolicyGatewayMetrics metrics,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "packId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Request body required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
GatewayForwardingContext? forwardingContext = null;
|
||||
var source = "service";
|
||||
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
|
||||
{
|
||||
forwardingContext = callerContext;
|
||||
source = "caller";
|
||||
}
|
||||
else if (!tokenProvider.IsEnabled)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
var response = await client.ActivatePolicyRevisionAsync(forwardingContext, packId, version, request, cancellationToken).ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
|
||||
var outcome = DetermineActivationOutcome(response);
|
||||
metrics.RecordActivation(outcome, source, stopwatch.Elapsed.TotalMilliseconds);
|
||||
|
||||
var logger = loggerFactory.CreateLogger("StellaOps.Policy.Gateway.Activation");
|
||||
LogActivation(logger, packId, version, outcome, source, response.StatusCode);
|
||||
|
||||
return response.ToMinimalResult();
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
|
||||
StellaOpsScopes.PolicyOperate,
|
||||
StellaOpsScopes.PolicyActivate));
|
||||
|
||||
app.Run();
|
||||
|
||||
static IAsyncPolicy<HttpResponseMessage> CreateAuthorityRetryPolicy(IServiceProvider provider)
|
||||
{
|
||||
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
var delays = authOptions.NormalizedRetryDelays;
|
||||
if (delays.Count == 0)
|
||||
{
|
||||
return Policy.NoOpAsync<HttpResponseMessage>();
|
||||
}
|
||||
|
||||
var loggerFactory = provider.GetService<ILoggerFactory>();
|
||||
var logger = loggerFactory?.CreateLogger("PolicyGateway.AuthorityHttp");
|
||||
|
||||
return HttpPolicyExtensions
|
||||
.HandleTransientHttpError()
|
||||
.OrResult(static message => message.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
.WaitAndRetryAsync(
|
||||
delays.Count,
|
||||
attempt => delays[attempt - 1],
|
||||
(outcome, delay, attempt, _) =>
|
||||
{
|
||||
logger?.LogWarning(
|
||||
outcome.Exception,
|
||||
"Retrying Authority HTTP call ({Attempt}/{Total}) after {Reason}; waiting {Delay}.",
|
||||
attempt,
|
||||
delays.Count,
|
||||
outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString(),
|
||||
delay);
|
||||
});
|
||||
}
|
||||
|
||||
static IAsyncPolicy<HttpResponseMessage> CreatePolicyEngineRetryPolicy(IServiceProvider provider)
|
||||
=> HttpPolicyExtensions
|
||||
.HandleTransientHttpError()
|
||||
.OrResult(static response => response.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout)
|
||||
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));
|
||||
|
||||
static string DetermineActivationOutcome(PolicyEngineResponse<PolicyRevisionActivationDto> response)
|
||||
{
|
||||
if (response.IsSuccess)
|
||||
{
|
||||
return response.Value?.Status switch
|
||||
{
|
||||
"activated" => "activated",
|
||||
"already_active" => "already_active",
|
||||
"pending_second_approval" => "pending_second_approval",
|
||||
_ => "success"
|
||||
};
|
||||
}
|
||||
|
||||
return response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.BadRequest => "bad_request",
|
||||
HttpStatusCode.NotFound => "not_found",
|
||||
HttpStatusCode.Unauthorized => "unauthorized",
|
||||
HttpStatusCode.Forbidden => "forbidden",
|
||||
_ => "error"
|
||||
};
|
||||
}
|
||||
|
||||
static void LogActivation(ILogger logger, string packId, int version, string outcome, string source, HttpStatusCode statusCode)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var message = "Policy activation forwarded.";
|
||||
var logLevel = outcome is "activated" or "already_active" or "pending_second_approval" ? LogLevel.Information : LogLevel.Warning;
|
||||
logger.Log(logLevel, message + " Outcome={Outcome}; Source={Source}; PackId={PackId}; Version={Version}; StatusCode={StatusCode}.", outcome, source, packId, version, (int)statusCode);
|
||||
}
|
||||
|
||||
public partial class Program
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Policy.Gateway.Tests")]
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
internal sealed class PolicyEngineTokenProvider
|
||||
{
|
||||
private readonly IStellaOpsTokenClient tokenClient;
|
||||
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
|
||||
private readonly PolicyGatewayDpopProofGenerator dpopGenerator;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<PolicyEngineTokenProvider> logger;
|
||||
private readonly SemaphoreSlim mutex = new(1, 1);
|
||||
private CachedToken? cachedToken;
|
||||
|
||||
public PolicyEngineTokenProvider(
|
||||
IStellaOpsTokenClient tokenClient,
|
||||
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
|
||||
PolicyGatewayDpopProofGenerator dpopGenerator,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PolicyEngineTokenProvider> logger)
|
||||
{
|
||||
this.tokenClient = tokenClient ?? throw new ArgumentNullException(nameof(tokenClient));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.dpopGenerator = dpopGenerator ?? throw new ArgumentNullException(nameof(dpopGenerator));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public bool IsEnabled => optionsMonitor.CurrentValue.PolicyEngine.ClientCredentials.Enabled;
|
||||
|
||||
public async ValueTask<PolicyGatewayAuthorization?> GetAuthorizationAsync(HttpMethod method, Uri targetUri, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tokenResult = await GetTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (tokenResult is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var token = tokenResult.Value;
|
||||
string? proof = null;
|
||||
if (dpopGenerator.Enabled)
|
||||
{
|
||||
proof = dpopGenerator.CreateProof(method, targetUri, token.AccessToken);
|
||||
}
|
||||
|
||||
var scheme = string.Equals(token.TokenType, "dpop", StringComparison.OrdinalIgnoreCase)
|
||||
? "DPoP"
|
||||
: token.TokenType;
|
||||
|
||||
var authorization = $"{scheme} {token.AccessToken}";
|
||||
return new PolicyGatewayAuthorization(authorization, proof, "service");
|
||||
}
|
||||
|
||||
private async ValueTask<CachedToken?> GetTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue.PolicyEngine;
|
||||
if (!options.ClientCredentials.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
if (cachedToken is { } existing && existing.ExpiresAt > now + TimeSpan.FromSeconds(30))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
await mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (cachedToken is { } cached && cached.ExpiresAt > now + TimeSpan.FromSeconds(30))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var scopeString = BuildScopeClaim(options);
|
||||
var result = await tokenClient.RequestClientCredentialsTokenAsync(scopeString, null, cancellationToken).ConfigureAwait(false);
|
||||
var expiresAt = result.ExpiresAtUtc;
|
||||
cachedToken = new CachedToken(result.AccessToken, string.IsNullOrWhiteSpace(result.TokenType) ? "Bearer" : result.TokenType, expiresAt);
|
||||
logger.LogInformation("Issued Policy Engine client credentials token; expires at {ExpiresAt:o}.", expiresAt);
|
||||
return cachedToken;
|
||||
}
|
||||
finally
|
||||
{
|
||||
mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildScopeClaim(PolicyGatewayPolicyEngineOptions options)
|
||||
{
|
||||
var scopeSet = new SortedSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
$"aud:{options.Audience.Trim().ToLowerInvariant()}"
|
||||
};
|
||||
|
||||
foreach (var scope in options.ClientCredentials.Scopes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
scopeSet.Add(scope.Trim());
|
||||
}
|
||||
|
||||
return string.Join(' ', scopeSet);
|
||||
}
|
||||
|
||||
private readonly record struct CachedToken(string AccessToken, string TokenType, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
internal readonly record struct PolicyGatewayAuthorization(string AuthorizationHeader, string? DpopProof, string Source)
|
||||
{
|
||||
public void Apply(HttpRequestMessage request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(AuthorizationHeader))
|
||||
{
|
||||
request.Headers.Remove("Authorization");
|
||||
request.Headers.TryAddWithoutValidation("Authorization", AuthorizationHeader);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(DpopProof))
|
||||
{
|
||||
request.Headers.Remove("DPoP");
|
||||
request.Headers.TryAddWithoutValidation("DPoP", DpopProof);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
internal sealed class PolicyGatewayDpopHandler : DelegatingHandler
|
||||
{
|
||||
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
|
||||
private readonly PolicyGatewayDpopProofGenerator proofGenerator;
|
||||
|
||||
public PolicyGatewayDpopHandler(
|
||||
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
|
||||
PolicyGatewayDpopProofGenerator proofGenerator)
|
||||
{
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.proofGenerator = proofGenerator ?? throw new ArgumentNullException(nameof(proofGenerator));
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
|
||||
if (options.Enabled &&
|
||||
proofGenerator.Enabled &&
|
||||
request.Method == HttpMethod.Post &&
|
||||
request.RequestUri is { } uri &&
|
||||
uri.AbsolutePath.Contains("/token", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var proof = proofGenerator.CreateProof(request.Method, uri, accessToken: null);
|
||||
request.Headers.Remove("DPoP");
|
||||
request.Headers.TryAddWithoutValidation("DPoP", proof);
|
||||
}
|
||||
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
internal sealed class PolicyGatewayDpopProofGenerator : IDisposable
|
||||
{
|
||||
private readonly IHostEnvironment hostEnvironment;
|
||||
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<PolicyGatewayDpopProofGenerator> logger;
|
||||
private DpopKeyMaterial? keyMaterial;
|
||||
private readonly object sync = new();
|
||||
|
||||
public PolicyGatewayDpopProofGenerator(
|
||||
IHostEnvironment hostEnvironment,
|
||||
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PolicyGatewayDpopProofGenerator> logger)
|
||||
{
|
||||
this.hostEnvironment = hostEnvironment ?? throw new ArgumentNullException(nameof(hostEnvironment));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public bool Enabled
|
||||
{
|
||||
get
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
|
||||
return options.Enabled;
|
||||
}
|
||||
}
|
||||
|
||||
public string CreateProof(HttpMethod method, Uri targetUri, string? accessToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(method);
|
||||
ArgumentNullException.ThrowIfNull(targetUri);
|
||||
|
||||
if (!Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("DPoP proof requested while DPoP is disabled.");
|
||||
}
|
||||
|
||||
var material = GetOrLoadKeyMaterial();
|
||||
var header = CreateHeader(material);
|
||||
var payload = CreatePayload(method, targetUri, accessToken);
|
||||
|
||||
var jwt = new JwtSecurityToken(header, payload);
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
return handler.WriteToken(jwt);
|
||||
}
|
||||
|
||||
private JwtHeader CreateHeader(DpopKeyMaterial material)
|
||||
{
|
||||
var header = new JwtHeader(new SigningCredentials(material.SecurityKey, material.SigningAlgorithm));
|
||||
header["typ"] = "dpop+jwt";
|
||||
header["jwk"] = new Dictionary<string, object>
|
||||
{
|
||||
["kty"] = material.Jwk.Kty,
|
||||
["crv"] = material.Jwk.Crv,
|
||||
["x"] = material.Jwk.X,
|
||||
["y"] = material.Jwk.Y,
|
||||
["kid"] = material.Jwk.Kid
|
||||
};
|
||||
return header;
|
||||
}
|
||||
|
||||
private JwtPayload CreatePayload(HttpMethod method, Uri targetUri, string? accessToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var epochSeconds = (long)Math.Floor((now - DateTimeOffset.UnixEpoch).TotalSeconds);
|
||||
var payload = new JwtPayload
|
||||
{
|
||||
["htm"] = method.Method.ToUpperInvariant(),
|
||||
["htu"] = NormalizeTarget(targetUri),
|
||||
["iat"] = epochSeconds,
|
||||
["jti"] = Guid.NewGuid().ToString("N")
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(accessToken));
|
||||
payload["ath"] = Base64UrlEncoder.Encode(hash);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static string NormalizeTarget(Uri uri)
|
||||
{
|
||||
if (!uri.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("DPoP proofs require absolute target URIs.");
|
||||
}
|
||||
|
||||
return uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped);
|
||||
}
|
||||
|
||||
private DpopKeyMaterial GetOrLoadKeyMaterial()
|
||||
{
|
||||
if (keyMaterial is not null)
|
||||
{
|
||||
return keyMaterial;
|
||||
}
|
||||
|
||||
lock (sync)
|
||||
{
|
||||
if (keyMaterial is not null)
|
||||
{
|
||||
return keyMaterial;
|
||||
}
|
||||
|
||||
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
|
||||
if (!options.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("DPoP is not enabled in the current configuration.");
|
||||
}
|
||||
|
||||
var resolvedPath = ResolveKeyPath(options.KeyPath);
|
||||
if (!File.Exists(resolvedPath))
|
||||
{
|
||||
throw new FileNotFoundException($"DPoP key file not found at '{resolvedPath}'.", resolvedPath);
|
||||
}
|
||||
|
||||
var pem = File.ReadAllText(resolvedPath);
|
||||
ECDsa ecdsa;
|
||||
try
|
||||
{
|
||||
ecdsa = ECDsa.Create();
|
||||
if (!string.IsNullOrWhiteSpace(options.KeyPassphrase))
|
||||
{
|
||||
ecdsa.ImportFromEncryptedPem(pem, options.KeyPassphrase);
|
||||
}
|
||||
else
|
||||
{
|
||||
ecdsa.ImportFromPem(pem);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to load DPoP private key.", ex);
|
||||
}
|
||||
|
||||
var securityKey = new ECDsaSecurityKey(ecdsa)
|
||||
{
|
||||
KeyId = ComputeKeyId(ecdsa)
|
||||
};
|
||||
|
||||
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey);
|
||||
jwk.Kid ??= securityKey.KeyId;
|
||||
|
||||
keyMaterial = new DpopKeyMaterial(ecdsa, securityKey, jwk, MapAlgorithm(options.Algorithm));
|
||||
logger.LogInformation("Loaded DPoP key from {Path} (alg: {Algorithm}).", resolvedPath, options.Algorithm);
|
||||
return keyMaterial;
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveKeyPath(string path)
|
||||
{
|
||||
if (Path.IsPathRooted(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, path));
|
||||
}
|
||||
|
||||
private static string ComputeKeyId(ECDsa ecdsa)
|
||||
{
|
||||
var parameters = ecdsa.ExportParameters(includePrivateParameters: false);
|
||||
var buffer = new byte[(parameters.Q.X?.Length ?? 0) + (parameters.Q.Y?.Length ?? 0)];
|
||||
var offset = 0;
|
||||
if (parameters.Q.X is not null)
|
||||
{
|
||||
Buffer.BlockCopy(parameters.Q.X, 0, buffer, offset, parameters.Q.X.Length);
|
||||
offset += parameters.Q.X.Length;
|
||||
}
|
||||
|
||||
if (parameters.Q.Y is not null)
|
||||
{
|
||||
Buffer.BlockCopy(parameters.Q.Y, 0, buffer, offset, parameters.Q.Y.Length);
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(buffer);
|
||||
return Base64UrlEncoder.Encode(hash);
|
||||
}
|
||||
|
||||
private static string MapAlgorithm(string algorithm)
|
||||
=> algorithm switch
|
||||
{
|
||||
"ES256" => SecurityAlgorithms.EcdsaSha256,
|
||||
"ES384" => SecurityAlgorithms.EcdsaSha384,
|
||||
_ => throw new InvalidOperationException($"Unsupported DPoP signing algorithm '{algorithm}'.")
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (keyMaterial is { } material)
|
||||
{
|
||||
material.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DpopKeyMaterial : IDisposable
|
||||
{
|
||||
public DpopKeyMaterial(ECDsa ecdsa, ECDsaSecurityKey securityKey, JsonWebKey jwk, string signingAlgorithm)
|
||||
{
|
||||
Ecdsa = ecdsa;
|
||||
SecurityKey = securityKey;
|
||||
Jwk = jwk;
|
||||
SigningAlgorithm = signingAlgorithm;
|
||||
}
|
||||
|
||||
public ECDsa Ecdsa { get; }
|
||||
public ECDsaSecurityKey SecurityKey { get; }
|
||||
public JsonWebKey Jwk { get; }
|
||||
public string SigningAlgorithm { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Ecdsa.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
internal sealed class PolicyGatewayMetrics : IDisposable
|
||||
{
|
||||
private static readonly KeyValuePair<string, object?>[] EmptyTags = Array.Empty<KeyValuePair<string, object?>>();
|
||||
|
||||
private readonly Meter meter;
|
||||
|
||||
public PolicyGatewayMetrics()
|
||||
{
|
||||
meter = new Meter("StellaOps.Policy.Gateway", "1.0.0");
|
||||
ActivationRequests = meter.CreateCounter<long>(
|
||||
"policy_gateway_activation_requests_total",
|
||||
unit: "count",
|
||||
description: "Total policy activation proxy requests processed by the gateway.");
|
||||
ActivationLatencyMs = meter.CreateHistogram<double>(
|
||||
"policy_gateway_activation_latency_ms",
|
||||
unit: "ms",
|
||||
description: "Latency distribution for policy activation proxy calls.");
|
||||
}
|
||||
|
||||
public Counter<long> ActivationRequests { get; }
|
||||
|
||||
public Histogram<double> ActivationLatencyMs { get; }
|
||||
|
||||
public void RecordActivation(string outcome, string source, double elapsedMilliseconds)
|
||||
{
|
||||
var tags = BuildTags(outcome, source);
|
||||
ActivationRequests.Add(1, tags);
|
||||
ActivationLatencyMs.Record(elapsedMilliseconds, tags);
|
||||
}
|
||||
|
||||
private static KeyValuePair<string, object?>[] BuildTags(string outcome, string source)
|
||||
{
|
||||
outcome = string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome;
|
||||
source = string.IsNullOrWhiteSpace(source) ? "unspecified" : source;
|
||||
return new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("outcome", outcome),
|
||||
new KeyValuePair<string, object?>("source", source)
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
meter.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
34
src/Policy/StellaOps.Policy.Registry/AGENTS.md
Normal file
34
src/Policy/StellaOps.Policy.Registry/AGENTS.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Policy Registry Guild Charter
|
||||
|
||||
## Mission
|
||||
Stand up and operate the Policy Registry service defined in Epic 4. We own workspace storage, version immutability, simulation orchestration metadata, attestations, and RBAC enforcement for the policy lifecycle.
|
||||
|
||||
## Scope
|
||||
- Service source under `src/Policy/StellaOps.Policy.Registry` (REST API, workers, storage schemas).
|
||||
- Mongo models, migrations, and object storage bindings for policy workspaces, versions, reviews, promotions, simulations.
|
||||
- Integration with Policy Engine, Scheduler, Authority, Web Gateway, Telemetry.
|
||||
- Attestation signing pipeline, evidence bundle management, and retention policies.
|
||||
|
||||
## Principles
|
||||
1. **Immutability first** – Published versions are append-only; derive new versions rather than mutate.
|
||||
2. **Determinism** – Compilation/simulation requests must produce reproducible artifacts and checksums.
|
||||
3. **Tenant isolation** – Enforce scoping at every storage layer (Mongo collections, buckets, queues).
|
||||
4. **AOC alignment** – Registry stores metadata; it never mutates raw SBOM/advisory/VEX facts.
|
||||
5. **Auditable** – Every transition emits structured events with actor, scope, digest, attestation IDs.
|
||||
|
||||
## Collaboration
|
||||
- Keep `src/Policy/StellaOps.Policy.Registry/TASKS.md`, `../../docs/implplan/SPRINTS.md` synchronized.
|
||||
- Coordinate API contracts with Policy Engine (`src/Policy/StellaOps.Policy.Engine`), Web Gateway (`src/Web/StellaOps.Web`), Console (`/console`), CLI (`src/Cli/StellaOps.Cli`), and Docs.
|
||||
- Publish or update OpenAPI specs under `src/Policy/StellaOps.Policy.Registry/openapi/` and hand them to client teams.
|
||||
|
||||
## Tooling
|
||||
- .NET 10 preview (minimal API + background workers).
|
||||
- MongoDB with per-tenant collections, S3-compatible object storage for bundles.
|
||||
- Background queue (Scheduler job queue or NATS) for batch simulations.
|
||||
- Signing via Authority-issued OIDC tokens + cosign integration.
|
||||
|
||||
## Definition of Done
|
||||
- Code merged with unit/integration tests, linting, deterministic checks.
|
||||
- Telemetry (metrics/logs/traces) wired with tenant context.
|
||||
- Docs/reference updated; OpenAPI regenerated.
|
||||
- Feature flags + configuration defaults documented.
|
||||
17
src/Policy/StellaOps.Policy.Registry/TASKS.md
Normal file
17
src/Policy/StellaOps.Policy.Registry/TASKS.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Policy Registry Task Board — Epic 4: Policy Studio
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| REGISTRY-API-27-001 | TODO | Policy Registry Guild | AUTH-CONSOLE-23-001, POLICY-ENGINE-20-001 | Define OpenAPI specification covering workspaces, versions, reviews, simulations, promotions, and attestations; publish typed clients for Console/CLI. | OpenAPI YAML committed, spectral lint passes, SDK regeneration documented, consumers notified. Docs `DOCS-POLICY-27-001/008/010` waiting on this spec. |
|
||||
| REGISTRY-API-27-002 | TODO | Policy Registry Guild | REGISTRY-API-27-001 | Implement workspace storage (Mongo collections, object storage buckets) with CRUD endpoints, diff history, and retention policies. | Workspace CRUD passes integration tests; retention job documented; tenancy scopes enforced. |
|
||||
| REGISTRY-API-27-003 | TODO | Policy Registry Guild | REGISTRY-API-27-002, POLICY-ENGINE-20-001 | Integrate compile endpoint: forward source bundle to Policy Engine, persist diagnostics, symbol table, rule index, and complexity metrics. | Compile API returns diagnostics + symbol table, metrics recorded, failures mapped to `ERR_POL_*`, tests cover success/error cases. |
|
||||
| REGISTRY-API-27-004 | TODO | Policy Registry Guild | REGISTRY-API-27-003, POLICY-ENGINE-20-002 | Implement quick simulation API with request limits (sample size, timeouts), returning counts, heatmap, sampled explains. | Quick sim enforces limits, results cached with hash, integration tests validate deterministic output. |
|
||||
| REGISTRY-API-27-005 | TODO | Policy Registry Guild, Scheduler Guild | REGISTRY-API-27-004, SCHED-WORKER-27-301 | Build batch simulation orchestration: enqueue shards, collect partials, reduce deltas, produce evidence bundles + signed manifest. | Batch sim runs end-to-end in staging fixture, manifests stored with checksums, retries/backoff documented. |
|
||||
> Docs dependency: `DOCS-POLICY-27-004` needs simulation APIs/workers.
|
||||
| REGISTRY-API-27-006 | TODO | Policy Registry Guild | REGISTRY-API-27-003 | Implement review workflow (comments, votes, required approvers, status transitions) with audit trails and webhooks. | Review endpoints enforce approver quorum, audit log captured, webhook integration tests pass. |
|
||||
> Docs dependency: `DOCS-POLICY-27-005` waiting on review workflow.
|
||||
| REGISTRY-API-27-007 | TODO | Policy Registry Guild, Security Guild | REGISTRY-API-27-006, AUTH-POLICY-27-001 | Implement publish pipeline: sign source/compiled digests, create attestations, mark version immutable, emit events. | Published versions immutable, attestations stored & verifiable, metrics/logs emitted, tests cover signing failure. |
|
||||
> Docs dependency: `DOCS-POLICY-27-003` blocked until publish/sign pipeline ships.
|
||||
| REGISTRY-API-27-008 | TODO | Policy Registry Guild | REGISTRY-API-27-007, AUTH-POLICY-27-002 | Implement promotion bindings per tenant/environment with canary subsets, rollback path, and environment history. | Promotion API updates bindings atomically, canary percent enforced, rollback recorded, runbooks updated. |
|
||||
> Docs dependency: `DOCS-POLICY-27-006` requires promotion APIs.
|
||||
| REGISTRY-API-27-009 | TODO | Policy Registry Guild, Observability Guild | REGISTRY-API-27-002..008 | Instrument metrics/logs/traces (compile time, diagnostics rate, sim queue depth, approval latency) and expose dashboards. | Metrics registered, dashboards seeded, alerts configured, documentation updated. |
|
||||
| REGISTRY-API-27-010 | TODO | Policy Registry Guild, QA Guild | REGISTRY-API-27-002..008 | Build unit/integration/load test suites for compile/sim/review/publish/promote flows; provide seeded fixtures for CI. | Tests run in CI, load test report documented, determinism checks validated across runs. |
|
||||
15
src/Policy/StellaOps.Policy.RiskProfile/AGENTS.md
Normal file
15
src/Policy/StellaOps.Policy.RiskProfile/AGENTS.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Risk Profile Schema Guild Charter
|
||||
|
||||
## Mission
|
||||
Define and maintain the RiskProfile schema, validation rules, inheritance logic, and integration with Policy Engine and Authority scoping.
|
||||
|
||||
## Scope
|
||||
- JSON Schema definition, validators, and code generation for RiskProfile documents.
|
||||
- Inheritance/merge engine, content hashing, and signature support.
|
||||
- Policy store integration, scope selectors, and lifecycle management.
|
||||
- Tooling for Policy Studio and CLI authoring.
|
||||
|
||||
## Definition of Done
|
||||
- Schema publishes via `.well-known/risk-profile-schema` with versioning.
|
||||
- Validators catch conflicts and produce actionable errors.
|
||||
- Inheritance and overrides deterministic with tests and golden fixtures.
|
||||
20
src/Policy/StellaOps.Policy.RiskProfile/TASKS.md
Normal file
20
src/Policy/StellaOps.Policy.RiskProfile/TASKS.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Risk Profile Schema Task Board — Epic 18: Risk Scoring Profiles
|
||||
|
||||
## Sprint 66 – Schema Foundations
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| POLICY-RISK-66-001 | TODO | Risk Profile Schema Guild | — | Develop initial JSON Schema for RiskProfile (signals, transforms, weights, severity, overrides) with validator stubs. | Schema published; validators unit-tested with positive/negative fixtures. |
|
||||
| POLICY-RISK-66-002 | TODO | Risk Profile Schema Guild | POLICY-RISK-66-001 | Implement inheritance/merge logic with conflict detection and deterministic content hashing. | Inheritance tests pass; hashes stable; documentation drafted. |
|
||||
|
||||
## Sprint 67 – Policy Store Integration
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| POLICY-RISK-67-001 | TODO | Risk Profile Schema Guild, Policy Engine Guild | POLICY-RISK-66-002 | Integrate profile storage and versioning into Policy Store with lifecycle states (draft/publish/deprecate). | Profiles persisted with status transitions; API returns versioned docs. |
|
||||
| POLICY-RISK-67-002 | TODO | Risk Profile Schema Guild | POLICY-RISK-67-001 | Publish `.well-known/risk-profile-schema` endpoint and CLI validation tooling. | Endpoint returns schema with version metadata; CLI `stella risk profile validate` uses schema. |
|
||||
|
||||
## Sprint 68 – Scope & Overrides
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| POLICY-RISK-68-001 | TODO | Risk Profile Schema Guild, Authority Guild | POLICY-RISK-67-001 | Implement scope selectors, precedence rules, and Authority attachment APIs. | Scope resolution works in tests; conflicts produce clear errors. |
|
||||
| POLICY-RISK-68-002 | TODO | Risk Profile Schema Guild | POLICY-RISK-66-002 | Add override/adjustment support with audit metadata and validation for conflicting rules. | Overrides validated; golden tests ensure deterministic ordering. |
|
||||
*** End Task Board ***
|
||||
212
src/Policy/StellaOps.Policy.sln
Normal file
212
src/Policy/StellaOps.Policy.sln
Normal file
@@ -0,0 +1,212 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.Engine", "StellaOps.Policy.Engine\StellaOps.Policy.Engine.csproj", "{83716724-0833-4EB4-BD13-7570DB47148E}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{41F15E67-7190-CF23-3BC4-77E87134CADD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy", "__Libraries\StellaOps.Policy\StellaOps.Policy.csproj", "{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{E33561D0-D9C4-42F0-A414-CC6439302E5F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{C4F44230-D5FF-425E-BC1B-2ECE59908B59}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{93944DA7-ED8C-466C-90DF-E3522DC49B08}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "..\Authority\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{45287280-FC03-4233-9012-193F4CE41964}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.Gateway", "StellaOps.Policy.Gateway\StellaOps.Policy.Gateway.csproj", "{6B83C5F2-EA81-4723-87EB-99101697B232}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{56BCE1BF-7CBA-7CE8-203D-A88051F1D642}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.Engine.Tests", "__Tests\StellaOps.Policy.Engine.Tests\StellaOps.Policy.Engine.Tests.csproj", "{478DF014-BF69-41BA-B78A-AAC0918337D8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.Gateway.Tests", "__Tests\StellaOps.Policy.Gateway.Tests\StellaOps.Policy.Gateway.Tests.csproj", "{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.Tests", "__Tests\StellaOps.Policy.Tests\StellaOps.Policy.Tests.csproj", "{D064D5C1-3311-470C-92A1-41E913125C14}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{83716724-0833-4EB4-BD13-7570DB47148E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{83716724-0833-4EB4-BD13-7570DB47148E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{83716724-0833-4EB4-BD13-7570DB47148E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{83716724-0833-4EB4-BD13-7570DB47148E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{83716724-0833-4EB4-BD13-7570DB47148E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{83716724-0833-4EB4-BD13-7570DB47148E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{83716724-0833-4EB4-BD13-7570DB47148E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{83716724-0833-4EB4-BD13-7570DB47148E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{83716724-0833-4EB4-BD13-7570DB47148E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{83716724-0833-4EB4-BD13-7570DB47148E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{83716724-0833-4EB4-BD13-7570DB47148E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{83716724-0833-4EB4-BD13-7570DB47148E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Release|x64.Build.0 = Release|Any CPU
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Release|x86.Build.0 = Release|Any CPU
|
||||
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{9F136BAA-6DBF-4FD5-ABD1-2648D1FA47AC}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E33561D0-D9C4-42F0-A414-CC6439302E5F}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C4F44230-D5FF-425E-BC1B-2ECE59908B59}.Release|x86.Build.0 = Release|Any CPU
|
||||
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Release|x64.Build.0 = Release|Any CPU
|
||||
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{93944DA7-ED8C-466C-90DF-E3522DC49B08}.Release|x86.Build.0 = Release|Any CPU
|
||||
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D9E27F55-32F4-42EE-AF96-DCC3B1DACD09}.Release|x86.Build.0 = Release|Any CPU
|
||||
{45287280-FC03-4233-9012-193F4CE41964}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{45287280-FC03-4233-9012-193F4CE41964}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{45287280-FC03-4233-9012-193F4CE41964}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{45287280-FC03-4233-9012-193F4CE41964}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{45287280-FC03-4233-9012-193F4CE41964}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{45287280-FC03-4233-9012-193F4CE41964}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{45287280-FC03-4233-9012-193F4CE41964}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{45287280-FC03-4233-9012-193F4CE41964}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{45287280-FC03-4233-9012-193F4CE41964}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{45287280-FC03-4233-9012-193F4CE41964}.Release|x64.Build.0 = Release|Any CPU
|
||||
{45287280-FC03-4233-9012-193F4CE41964}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{45287280-FC03-4233-9012-193F4CE41964}.Release|x86.Build.0 = Release|Any CPU
|
||||
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{DF8EBB6E-1C72-4AB9-A5BB-3BB9095499CC}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6B83C5F2-EA81-4723-87EB-99101697B232}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6B83C5F2-EA81-4723-87EB-99101697B232}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6B83C5F2-EA81-4723-87EB-99101697B232}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{6B83C5F2-EA81-4723-87EB-99101697B232}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{6B83C5F2-EA81-4723-87EB-99101697B232}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{6B83C5F2-EA81-4723-87EB-99101697B232}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{6B83C5F2-EA81-4723-87EB-99101697B232}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6B83C5F2-EA81-4723-87EB-99101697B232}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6B83C5F2-EA81-4723-87EB-99101697B232}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{6B83C5F2-EA81-4723-87EB-99101697B232}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6B83C5F2-EA81-4723-87EB-99101697B232}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6B83C5F2-EA81-4723-87EB-99101697B232}.Release|x86.Build.0 = Release|Any CPU
|
||||
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Release|x64.Build.0 = Release|Any CPU
|
||||
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{478DF014-BF69-41BA-B78A-AAC0918337D8}.Release|x86.Build.0 = Release|Any CPU
|
||||
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Release|x64.Build.0 = Release|Any CPU
|
||||
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44}.Release|x86.Build.0 = Release|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{478DF014-BF69-41BA-B78A-AAC0918337D8} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{77189D88-1CA1-46BD-A9DC-99B2B6EF7D44} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
12
src/Policy/__Libraries/StellaOps.Policy/AGENTS.md
Normal file
12
src/Policy/__Libraries/StellaOps.Policy/AGENTS.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# StellaOps.Policy — Agent Charter
|
||||
|
||||
## Mission
|
||||
Deliver the policy engine outlined in `docs/ARCHITECTURE_SCANNER.md` and related prose:
|
||||
- Define YAML schema (ignore rules, VEX inclusion/exclusion, vendor precedence, license gates).
|
||||
- Provide policy snapshot storage with revision digests and diagnostics.
|
||||
- Offer preview APIs to compare policy impacts on existing reports.
|
||||
|
||||
## Expectations
|
||||
- Coordinate with Scanner.WebService, Feedser, Vexer, UI, Notify.
|
||||
- Maintain deterministic serialization and unit tests for precedence rules.
|
||||
- Update `TASKS.md` and broadcast contract changes.
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public interface IPolicyAuditRepository
|
||||
{
|
||||
Task AddAsync(PolicyAuditEntry entry, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<PolicyAuditEntry>> ListAsync(int limit, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed class InMemoryPolicyAuditRepository : IPolicyAuditRepository
|
||||
{
|
||||
private readonly List<PolicyAuditEntry> _entries = new();
|
||||
private readonly SemaphoreSlim _mutex = new(1, 1);
|
||||
|
||||
public async Task AddAsync(PolicyAuditEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (entry is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(entry));
|
||||
}
|
||||
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_entries.Add(entry);
|
||||
_entries.Sort(static (left, right) => left.CreatedAt.CompareTo(right.CreatedAt));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PolicyAuditEntry>> ListAsync(int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
IEnumerable<PolicyAuditEntry> query = _entries;
|
||||
if (limit > 0)
|
||||
{
|
||||
query = query.TakeLast(limit);
|
||||
}
|
||||
|
||||
return query.ToImmutableArray();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/Policy/__Libraries/StellaOps.Policy/PolicyAuditEntry.cs
Normal file
12
src/Policy/__Libraries/StellaOps.Policy/PolicyAuditEntry.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyAuditEntry(
|
||||
Guid Id,
|
||||
DateTimeOffset CreatedAt,
|
||||
string Action,
|
||||
string RevisionId,
|
||||
string Digest,
|
||||
string? Actor,
|
||||
string Message);
|
||||
1234
src/Policy/__Libraries/StellaOps.Policy/PolicyBinder.cs
Normal file
1234
src/Policy/__Libraries/StellaOps.Policy/PolicyBinder.cs
Normal file
File diff suppressed because it is too large
Load Diff
77
src/Policy/__Libraries/StellaOps.Policy/PolicyDiagnostics.cs
Normal file
77
src/Policy/__Libraries/StellaOps.Policy/PolicyDiagnostics.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyDiagnosticsReport(
|
||||
string Version,
|
||||
int RuleCount,
|
||||
int ErrorCount,
|
||||
int WarningCount,
|
||||
DateTimeOffset GeneratedAt,
|
||||
ImmutableArray<PolicyIssue> Issues,
|
||||
ImmutableArray<string> Recommendations);
|
||||
|
||||
public static class PolicyDiagnostics
|
||||
{
|
||||
public static PolicyDiagnosticsReport Create(PolicyBindingResult bindingResult, TimeProvider? timeProvider = null)
|
||||
{
|
||||
if (bindingResult is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(bindingResult));
|
||||
}
|
||||
|
||||
var time = (timeProvider ?? TimeProvider.System).GetUtcNow();
|
||||
var errorCount = bindingResult.Issues.Count(static issue => issue.Severity == PolicyIssueSeverity.Error);
|
||||
var warningCount = bindingResult.Issues.Count(static issue => issue.Severity == PolicyIssueSeverity.Warning);
|
||||
|
||||
var recommendations = BuildRecommendations(bindingResult.Document, errorCount, warningCount);
|
||||
|
||||
return new PolicyDiagnosticsReport(
|
||||
bindingResult.Document.Version,
|
||||
bindingResult.Document.Rules.Length,
|
||||
errorCount,
|
||||
warningCount,
|
||||
time,
|
||||
bindingResult.Issues,
|
||||
recommendations);
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> BuildRecommendations(PolicyDocument document, int errorCount, int warningCount)
|
||||
{
|
||||
var messages = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
if (errorCount > 0)
|
||||
{
|
||||
messages.Add("Resolve policy errors before promoting the revision; fallback rules may be applied while errors remain.");
|
||||
}
|
||||
|
||||
if (warningCount > 0)
|
||||
{
|
||||
messages.Add("Review policy warnings and ensure intentional overrides are documented.");
|
||||
}
|
||||
|
||||
if (document.Rules.Length == 0)
|
||||
{
|
||||
messages.Add("Add at least one policy rule to enforce gating logic.");
|
||||
}
|
||||
|
||||
var quietRules = document.Rules
|
||||
.Where(static rule => rule.Action.Quiet)
|
||||
.Select(static rule => rule.Name)
|
||||
.ToArray();
|
||||
|
||||
if (quietRules.Length > 0)
|
||||
{
|
||||
messages.Add($"Quiet rules detected ({string.Join(", ", quietRules)}); verify scoring behaviour aligns with expectations.");
|
||||
}
|
||||
|
||||
if (messages.Count == 0)
|
||||
{
|
||||
messages.Add("Policy validated successfully; no additional action required.");
|
||||
}
|
||||
|
||||
return messages.ToImmutable();
|
||||
}
|
||||
}
|
||||
304
src/Policy/__Libraries/StellaOps.Policy/PolicyDigest.cs
Normal file
304
src/Policy/__Libraries/StellaOps.Policy/PolicyDigest.cs
Normal file
@@ -0,0 +1,304 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public static class PolicyDigest
|
||||
{
|
||||
public static string Compute(PolicyDocument document)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(document));
|
||||
}
|
||||
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
|
||||
{
|
||||
SkipValidation = true,
|
||||
}))
|
||||
{
|
||||
WriteDocument(writer, document);
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(buffer.WrittenSpan);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void WriteDocument(Utf8JsonWriter writer, PolicyDocument document)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("version", document.Version);
|
||||
|
||||
if (!document.Metadata.IsEmpty)
|
||||
{
|
||||
writer.WritePropertyName("metadata");
|
||||
writer.WriteStartObject();
|
||||
foreach (var pair in document.Metadata.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WriteString(pair.Key, pair.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WritePropertyName("rules");
|
||||
writer.WriteStartArray();
|
||||
foreach (var rule in document.Rules)
|
||||
{
|
||||
WriteRule(writer, rule);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
|
||||
if (!document.Exceptions.Effects.IsDefaultOrEmpty || !document.Exceptions.RoutingTemplates.IsDefaultOrEmpty)
|
||||
{
|
||||
writer.WritePropertyName("exceptions");
|
||||
writer.WriteStartObject();
|
||||
|
||||
if (!document.Exceptions.Effects.IsDefaultOrEmpty)
|
||||
{
|
||||
writer.WritePropertyName("effects");
|
||||
writer.WriteStartArray();
|
||||
foreach (var effect in document.Exceptions.Effects
|
||||
.OrderBy(static e => e.Id, StringComparer.Ordinal))
|
||||
{
|
||||
WriteExceptionEffect(writer, effect);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
if (!document.Exceptions.RoutingTemplates.IsDefaultOrEmpty)
|
||||
{
|
||||
writer.WritePropertyName("routingTemplates");
|
||||
writer.WriteStartArray();
|
||||
foreach (var template in document.Exceptions.RoutingTemplates
|
||||
.OrderBy(static t => t.Id, StringComparer.Ordinal))
|
||||
{
|
||||
WriteExceptionRoutingTemplate(writer, template);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
private static void WriteRule(Utf8JsonWriter writer, PolicyRule rule)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", rule.Name);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rule.Identifier))
|
||||
{
|
||||
writer.WriteString("id", rule.Identifier);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rule.Description))
|
||||
{
|
||||
writer.WriteString("description", rule.Description);
|
||||
}
|
||||
|
||||
WriteMetadata(writer, rule.Metadata);
|
||||
WriteSeverities(writer, rule.Severities);
|
||||
WriteStringArray(writer, "environments", rule.Environments);
|
||||
WriteStringArray(writer, "sources", rule.Sources);
|
||||
WriteStringArray(writer, "vendors", rule.Vendors);
|
||||
WriteStringArray(writer, "licenses", rule.Licenses);
|
||||
WriteStringArray(writer, "tags", rule.Tags);
|
||||
|
||||
if (!rule.Match.IsEmpty)
|
||||
{
|
||||
writer.WritePropertyName("match");
|
||||
writer.WriteStartObject();
|
||||
WriteStringArray(writer, "images", rule.Match.Images);
|
||||
WriteStringArray(writer, "repositories", rule.Match.Repositories);
|
||||
WriteStringArray(writer, "packages", rule.Match.Packages);
|
||||
WriteStringArray(writer, "purls", rule.Match.Purls);
|
||||
WriteStringArray(writer, "cves", rule.Match.Cves);
|
||||
WriteStringArray(writer, "paths", rule.Match.Paths);
|
||||
WriteStringArray(writer, "layerDigests", rule.Match.LayerDigests);
|
||||
WriteStringArray(writer, "usedByEntrypoint", rule.Match.UsedByEntrypoint);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
WriteAction(writer, rule.Action);
|
||||
|
||||
if (rule.Expires is DateTimeOffset expires)
|
||||
{
|
||||
writer.WriteString("expires", expires.ToUniversalTime().ToString("O"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rule.Justification))
|
||||
{
|
||||
writer.WriteString("justification", rule.Justification);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteAction(Utf8JsonWriter writer, PolicyAction action)
|
||||
{
|
||||
writer.WritePropertyName("action");
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", action.Type.ToString().ToLowerInvariant());
|
||||
|
||||
if (action.Quiet)
|
||||
{
|
||||
writer.WriteBoolean("quiet", true);
|
||||
}
|
||||
|
||||
if (action.Ignore is { } ignore)
|
||||
{
|
||||
if (ignore.Until is DateTimeOffset until)
|
||||
{
|
||||
writer.WriteString("until", until.ToUniversalTime().ToString("O"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ignore.Justification))
|
||||
{
|
||||
writer.WriteString("justification", ignore.Justification);
|
||||
}
|
||||
}
|
||||
|
||||
if (action.Escalate is { } escalate)
|
||||
{
|
||||
if (escalate.MinimumSeverity is { } severity)
|
||||
{
|
||||
writer.WriteString("severity", severity.ToString());
|
||||
}
|
||||
|
||||
if (escalate.RequireKev)
|
||||
{
|
||||
writer.WriteBoolean("kev", true);
|
||||
}
|
||||
|
||||
if (escalate.MinimumEpss is double epss)
|
||||
{
|
||||
writer.WriteNumber("epss", epss);
|
||||
}
|
||||
}
|
||||
|
||||
if (action.RequireVex is { } requireVex)
|
||||
{
|
||||
WriteStringArray(writer, "vendors", requireVex.Vendors);
|
||||
WriteStringArray(writer, "justifications", requireVex.Justifications);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteMetadata(Utf8JsonWriter writer, ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
if (metadata.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WritePropertyName("metadata");
|
||||
writer.WriteStartObject();
|
||||
foreach (var pair in metadata.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WriteString(pair.Key, pair.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteSeverities(Utf8JsonWriter writer, ImmutableArray<PolicySeverity> severities)
|
||||
{
|
||||
if (severities.IsDefaultOrEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WritePropertyName("severity");
|
||||
writer.WriteStartArray();
|
||||
foreach (var severity in severities)
|
||||
{
|
||||
writer.WriteStringValue(severity.ToString());
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteStringArray(Utf8JsonWriter writer, string propertyName, ImmutableArray<string> values)
|
||||
{
|
||||
if (values.IsDefaultOrEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WritePropertyName(propertyName);
|
||||
writer.WriteStartArray();
|
||||
foreach (var value in values)
|
||||
{
|
||||
writer.WriteStringValue(value);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteExceptionEffect(Utf8JsonWriter writer, PolicyExceptionEffect effect)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("id", effect.Id);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(effect.Name))
|
||||
{
|
||||
writer.WriteString("name", effect.Name);
|
||||
}
|
||||
|
||||
writer.WriteString("effect", effect.Effect.ToString().ToLowerInvariant());
|
||||
|
||||
if (effect.DowngradeSeverity is { } downgradeSeverity)
|
||||
{
|
||||
writer.WriteString("downgradeSeverity", downgradeSeverity.ToString());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(effect.RequiredControlId))
|
||||
{
|
||||
writer.WriteString("requiredControlId", effect.RequiredControlId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(effect.RoutingTemplate))
|
||||
{
|
||||
writer.WriteString("routingTemplate", effect.RoutingTemplate);
|
||||
}
|
||||
|
||||
if (effect.MaxDurationDays is int maxDurationDays)
|
||||
{
|
||||
writer.WriteNumber("maxDurationDays", maxDurationDays);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(effect.Description))
|
||||
{
|
||||
writer.WriteString("description", effect.Description);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteExceptionRoutingTemplate(Utf8JsonWriter writer, PolicyExceptionRoutingTemplate template)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("id", template.Id);
|
||||
writer.WriteString("authorityRouteId", template.AuthorityRouteId);
|
||||
|
||||
if (template.RequireMfa)
|
||||
{
|
||||
writer.WriteBoolean("requireMfa", true);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(template.Description))
|
||||
{
|
||||
writer.WriteString("description", template.Description);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
241
src/Policy/__Libraries/StellaOps.Policy/PolicyDocument.cs
Normal file
241
src/Policy/__Libraries/StellaOps.Policy/PolicyDocument.cs
Normal file
@@ -0,0 +1,241 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical representation of a StellaOps policy document.
|
||||
/// </summary>
|
||||
public sealed record PolicyDocument(
|
||||
string Version,
|
||||
ImmutableArray<PolicyRule> Rules,
|
||||
ImmutableDictionary<string, string> Metadata,
|
||||
PolicyExceptionConfiguration Exceptions)
|
||||
{
|
||||
public static PolicyDocument Empty { get; } = new(
|
||||
PolicySchema.CurrentVersion,
|
||||
ImmutableArray<PolicyRule>.Empty,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
PolicyExceptionConfiguration.Empty);
|
||||
}
|
||||
|
||||
public static class PolicySchema
|
||||
{
|
||||
public const string SchemaId = "https://schemas.stella-ops.org/policy/policy-schema@1.json";
|
||||
public const string CurrentVersion = "1.0";
|
||||
|
||||
public static PolicyDocumentFormat DetectFormat(string fileName)
|
||||
{
|
||||
if (fileName is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(fileName));
|
||||
}
|
||||
|
||||
var lower = fileName.Trim().ToLowerInvariant();
|
||||
if (lower.EndsWith(".yaml", StringComparison.Ordinal)
|
||||
|| lower.EndsWith(".yml", StringComparison.Ordinal)
|
||||
|| lower.EndsWith(".stella", StringComparison.Ordinal))
|
||||
{
|
||||
return PolicyDocumentFormat.Yaml;
|
||||
}
|
||||
|
||||
return PolicyDocumentFormat.Json;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PolicyRule(
|
||||
string Name,
|
||||
string? Identifier,
|
||||
string? Description,
|
||||
PolicyAction Action,
|
||||
ImmutableArray<PolicySeverity> Severities,
|
||||
ImmutableArray<string> Environments,
|
||||
ImmutableArray<string> Sources,
|
||||
ImmutableArray<string> Vendors,
|
||||
ImmutableArray<string> Licenses,
|
||||
ImmutableArray<string> Tags,
|
||||
PolicyRuleMatchCriteria Match,
|
||||
DateTimeOffset? Expires,
|
||||
string? Justification,
|
||||
ImmutableDictionary<string, string> Metadata)
|
||||
{
|
||||
public static PolicyRuleMatchCriteria EmptyMatch { get; } = new(
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty);
|
||||
|
||||
public static PolicyRule Create(
|
||||
string name,
|
||||
PolicyAction action,
|
||||
ImmutableArray<PolicySeverity> severities,
|
||||
ImmutableArray<string> environments,
|
||||
ImmutableArray<string> sources,
|
||||
ImmutableArray<string> vendors,
|
||||
ImmutableArray<string> licenses,
|
||||
ImmutableArray<string> tags,
|
||||
PolicyRuleMatchCriteria match,
|
||||
DateTimeOffset? expires,
|
||||
string? justification,
|
||||
string? identifier = null,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null)
|
||||
{
|
||||
metadata ??= ImmutableDictionary<string, string>.Empty;
|
||||
return new PolicyRule(
|
||||
name,
|
||||
identifier,
|
||||
description,
|
||||
action,
|
||||
severities,
|
||||
environments,
|
||||
sources,
|
||||
vendors,
|
||||
licenses,
|
||||
tags,
|
||||
match,
|
||||
expires,
|
||||
justification,
|
||||
metadata);
|
||||
}
|
||||
|
||||
public bool MatchesAnyEnvironment => Environments.IsDefaultOrEmpty;
|
||||
}
|
||||
|
||||
public sealed record PolicyRuleMatchCriteria(
|
||||
ImmutableArray<string> Images,
|
||||
ImmutableArray<string> Repositories,
|
||||
ImmutableArray<string> Packages,
|
||||
ImmutableArray<string> Purls,
|
||||
ImmutableArray<string> Cves,
|
||||
ImmutableArray<string> Paths,
|
||||
ImmutableArray<string> LayerDigests,
|
||||
ImmutableArray<string> UsedByEntrypoint)
|
||||
{
|
||||
public static PolicyRuleMatchCriteria Create(
|
||||
ImmutableArray<string> images,
|
||||
ImmutableArray<string> repositories,
|
||||
ImmutableArray<string> packages,
|
||||
ImmutableArray<string> purls,
|
||||
ImmutableArray<string> cves,
|
||||
ImmutableArray<string> paths,
|
||||
ImmutableArray<string> layerDigests,
|
||||
ImmutableArray<string> usedByEntrypoint)
|
||||
=> new(
|
||||
images,
|
||||
repositories,
|
||||
packages,
|
||||
purls,
|
||||
cves,
|
||||
paths,
|
||||
layerDigests,
|
||||
usedByEntrypoint);
|
||||
|
||||
public static PolicyRuleMatchCriteria Empty { get; } = new(
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty);
|
||||
|
||||
public bool IsEmpty =>
|
||||
Images.IsDefaultOrEmpty &&
|
||||
Repositories.IsDefaultOrEmpty &&
|
||||
Packages.IsDefaultOrEmpty &&
|
||||
Purls.IsDefaultOrEmpty &&
|
||||
Cves.IsDefaultOrEmpty &&
|
||||
Paths.IsDefaultOrEmpty &&
|
||||
LayerDigests.IsDefaultOrEmpty &&
|
||||
UsedByEntrypoint.IsDefaultOrEmpty;
|
||||
}
|
||||
|
||||
public sealed record PolicyAction(
|
||||
PolicyActionType Type,
|
||||
PolicyIgnoreOptions? Ignore,
|
||||
PolicyEscalateOptions? Escalate,
|
||||
PolicyRequireVexOptions? RequireVex,
|
||||
bool Quiet);
|
||||
|
||||
public enum PolicyActionType
|
||||
{
|
||||
Block,
|
||||
Ignore,
|
||||
Warn,
|
||||
Defer,
|
||||
Escalate,
|
||||
RequireVex,
|
||||
}
|
||||
|
||||
public sealed record PolicyIgnoreOptions(DateTimeOffset? Until, string? Justification);
|
||||
|
||||
public sealed record PolicyEscalateOptions(
|
||||
PolicySeverity? MinimumSeverity,
|
||||
bool RequireKev,
|
||||
double? MinimumEpss);
|
||||
|
||||
public sealed record PolicyRequireVexOptions(
|
||||
ImmutableArray<string> Vendors,
|
||||
ImmutableArray<string> Justifications);
|
||||
|
||||
public enum PolicySeverity
|
||||
{
|
||||
Critical,
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
Informational,
|
||||
None,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
public sealed record PolicyExceptionConfiguration(
|
||||
ImmutableArray<PolicyExceptionEffect> Effects,
|
||||
ImmutableArray<PolicyExceptionRoutingTemplate> RoutingTemplates)
|
||||
{
|
||||
public static PolicyExceptionConfiguration Empty { get; } = new(
|
||||
ImmutableArray<PolicyExceptionEffect>.Empty,
|
||||
ImmutableArray<PolicyExceptionRoutingTemplate>.Empty);
|
||||
|
||||
public PolicyExceptionEffect? FindEffect(string effectId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(effectId) || Effects.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Effects.FirstOrDefault(effect =>
|
||||
string.Equals(effect.Id, effectId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PolicyExceptionEffect(
|
||||
string Id,
|
||||
string? Name,
|
||||
PolicyExceptionEffectType Effect,
|
||||
PolicySeverity? DowngradeSeverity,
|
||||
string? RequiredControlId,
|
||||
string? RoutingTemplate,
|
||||
int? MaxDurationDays,
|
||||
string? Description);
|
||||
|
||||
public enum PolicyExceptionEffectType
|
||||
{
|
||||
Suppress,
|
||||
Defer,
|
||||
Downgrade,
|
||||
RequireControl,
|
||||
}
|
||||
|
||||
public sealed record PolicyExceptionRoutingTemplate(
|
||||
string Id,
|
||||
string AuthorityRouteId,
|
||||
bool RequireMfa,
|
||||
string? Description);
|
||||
552
src/Policy/__Libraries/StellaOps.Policy/PolicyEvaluation.cs
Normal file
552
src/Policy/__Libraries/StellaOps.Policy/PolicyEvaluation.cs
Normal file
@@ -0,0 +1,552 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public static class PolicyEvaluation
|
||||
{
|
||||
public static PolicyVerdict EvaluateFinding(PolicyDocument document, PolicyScoringConfig scoringConfig, PolicyFinding finding)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(document));
|
||||
}
|
||||
|
||||
if (scoringConfig is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(scoringConfig));
|
||||
}
|
||||
|
||||
if (finding is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(finding));
|
||||
}
|
||||
|
||||
var severityWeight = scoringConfig.SeverityWeights.TryGetValue(finding.Severity, out var weight)
|
||||
? weight
|
||||
: scoringConfig.SeverityWeights.GetValueOrDefault(PolicySeverity.Unknown, 0);
|
||||
var trustKey = ResolveTrustKey(finding);
|
||||
var trustWeight = ResolveTrustWeight(scoringConfig, trustKey);
|
||||
var reachabilityKey = ResolveReachabilityKey(finding);
|
||||
var reachabilityWeight = ResolveReachabilityWeight(scoringConfig, reachabilityKey, out var resolvedReachabilityKey);
|
||||
var baseScore = severityWeight * trustWeight * reachabilityWeight;
|
||||
var components = new ScoringComponents(
|
||||
severityWeight,
|
||||
trustWeight,
|
||||
reachabilityWeight,
|
||||
baseScore,
|
||||
trustKey,
|
||||
resolvedReachabilityKey);
|
||||
var unknownConfidence = ComputeUnknownConfidence(scoringConfig.UnknownConfidence, finding);
|
||||
|
||||
foreach (var rule in document.Rules)
|
||||
{
|
||||
if (!RuleMatches(rule, finding))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return BuildVerdict(rule, finding, scoringConfig, components, unknownConfidence);
|
||||
}
|
||||
|
||||
var baseline = PolicyVerdict.CreateBaseline(finding.FindingId, scoringConfig);
|
||||
return ApplyUnknownConfidence(baseline, unknownConfidence);
|
||||
}
|
||||
|
||||
private static PolicyVerdict BuildVerdict(
|
||||
PolicyRule rule,
|
||||
PolicyFinding finding,
|
||||
PolicyScoringConfig config,
|
||||
ScoringComponents components,
|
||||
UnknownConfidenceResult? unknownConfidence)
|
||||
{
|
||||
var action = rule.Action;
|
||||
var status = MapAction(action);
|
||||
var notes = BuildNotes(action);
|
||||
var inputs = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
inputs["severityWeight"] = components.SeverityWeight;
|
||||
inputs["trustWeight"] = components.TrustWeight;
|
||||
inputs["reachabilityWeight"] = components.ReachabilityWeight;
|
||||
inputs["baseScore"] = components.BaseScore;
|
||||
if (!string.IsNullOrWhiteSpace(components.TrustKey))
|
||||
{
|
||||
inputs[$"trustWeight.{components.TrustKey}"] = components.TrustWeight;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(components.ReachabilityKey))
|
||||
{
|
||||
inputs[$"reachability.{components.ReachabilityKey}"] = components.ReachabilityWeight;
|
||||
}
|
||||
if (unknownConfidence is { Band.Description: { Length: > 0 } description })
|
||||
{
|
||||
notes = AppendNote(notes, description);
|
||||
}
|
||||
if (unknownConfidence is { } unknownDetails)
|
||||
{
|
||||
inputs["unknownConfidence"] = unknownDetails.Confidence;
|
||||
inputs["unknownAgeDays"] = unknownDetails.AgeDays;
|
||||
}
|
||||
|
||||
double score = components.BaseScore;
|
||||
string? quietedBy = null;
|
||||
var quiet = false;
|
||||
|
||||
var quietRequested = action.Quiet;
|
||||
var quietAllowed = quietRequested && (action.RequireVex is not null || action.Type == PolicyActionType.RequireVex);
|
||||
|
||||
if (quietRequested && !quietAllowed)
|
||||
{
|
||||
var warnInputs = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in inputs)
|
||||
{
|
||||
warnInputs[pair.Key] = pair.Value;
|
||||
}
|
||||
if (unknownConfidence is { } unknownInfo)
|
||||
{
|
||||
warnInputs["unknownConfidence"] = unknownInfo.Confidence;
|
||||
warnInputs["unknownAgeDays"] = unknownInfo.AgeDays;
|
||||
}
|
||||
|
||||
var warnPenalty = config.WarnPenalty;
|
||||
warnInputs["warnPenalty"] = warnPenalty;
|
||||
var warnScore = Math.Max(0, components.BaseScore - warnPenalty);
|
||||
var warnNotes = AppendNote(notes, "Quiet flag ignored: rule must specify requireVex justifications.");
|
||||
|
||||
return new PolicyVerdict(
|
||||
finding.FindingId,
|
||||
PolicyVerdictStatus.Warned,
|
||||
rule.Name,
|
||||
action.Type.ToString(),
|
||||
warnNotes,
|
||||
warnScore,
|
||||
config.Version,
|
||||
warnInputs.ToImmutable(),
|
||||
QuietedBy: null,
|
||||
Quiet: false,
|
||||
UnknownConfidence: unknownConfidence?.Confidence,
|
||||
ConfidenceBand: unknownConfidence?.Band.Name,
|
||||
UnknownAgeDays: unknownConfidence?.AgeDays,
|
||||
SourceTrust: components.TrustKey,
|
||||
Reachability: components.ReachabilityKey);
|
||||
}
|
||||
|
||||
switch (status)
|
||||
{
|
||||
case PolicyVerdictStatus.Ignored:
|
||||
score = ApplyPenalty(score, config.IgnorePenalty, inputs, "ignorePenalty");
|
||||
break;
|
||||
case PolicyVerdictStatus.Warned:
|
||||
score = ApplyPenalty(score, config.WarnPenalty, inputs, "warnPenalty");
|
||||
break;
|
||||
case PolicyVerdictStatus.Deferred:
|
||||
var deferPenalty = config.WarnPenalty / 2;
|
||||
score = ApplyPenalty(score, deferPenalty, inputs, "deferPenalty");
|
||||
break;
|
||||
}
|
||||
|
||||
if (quietAllowed)
|
||||
{
|
||||
score = ApplyPenalty(score, config.QuietPenalty, inputs, "quietPenalty");
|
||||
quietedBy = rule.Name;
|
||||
quiet = true;
|
||||
}
|
||||
|
||||
return new PolicyVerdict(
|
||||
finding.FindingId,
|
||||
status,
|
||||
rule.Name,
|
||||
action.Type.ToString(),
|
||||
notes,
|
||||
score,
|
||||
config.Version,
|
||||
inputs.ToImmutable(),
|
||||
quietedBy,
|
||||
quiet,
|
||||
unknownConfidence?.Confidence,
|
||||
unknownConfidence?.Band.Name,
|
||||
unknownConfidence?.AgeDays,
|
||||
components.TrustKey,
|
||||
components.ReachabilityKey);
|
||||
}
|
||||
|
||||
private static double ApplyPenalty(double score, double penalty, ImmutableDictionary<string, double>.Builder inputs, string key)
|
||||
{
|
||||
if (penalty <= 0)
|
||||
{
|
||||
return score;
|
||||
}
|
||||
|
||||
inputs[key] = penalty;
|
||||
return Math.Max(0, score - penalty);
|
||||
}
|
||||
|
||||
private static PolicyVerdict ApplyUnknownConfidence(PolicyVerdict verdict, UnknownConfidenceResult? unknownConfidence)
|
||||
{
|
||||
if (unknownConfidence is null)
|
||||
{
|
||||
return verdict;
|
||||
}
|
||||
|
||||
var inputsBuilder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in verdict.GetInputs())
|
||||
{
|
||||
inputsBuilder[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
inputsBuilder["unknownConfidence"] = unknownConfidence.Value.Confidence;
|
||||
inputsBuilder["unknownAgeDays"] = unknownConfidence.Value.AgeDays;
|
||||
|
||||
return verdict with
|
||||
{
|
||||
Inputs = inputsBuilder.ToImmutable(),
|
||||
UnknownConfidence = unknownConfidence.Value.Confidence,
|
||||
ConfidenceBand = unknownConfidence.Value.Band.Name,
|
||||
UnknownAgeDays = unknownConfidence.Value.AgeDays,
|
||||
};
|
||||
}
|
||||
|
||||
private static UnknownConfidenceResult? ComputeUnknownConfidence(PolicyUnknownConfidenceConfig config, PolicyFinding finding)
|
||||
{
|
||||
if (!IsUnknownFinding(finding))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ageDays = ResolveUnknownAgeDays(finding);
|
||||
var rawConfidence = config.Initial - (ageDays * config.DecayPerDay);
|
||||
var confidence = config.Clamp(rawConfidence);
|
||||
var band = config.ResolveBand(confidence);
|
||||
return new UnknownConfidenceResult(ageDays, confidence, band);
|
||||
}
|
||||
|
||||
private static bool IsUnknownFinding(PolicyFinding finding)
|
||||
{
|
||||
if (finding.Severity == PolicySeverity.Unknown)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!finding.Tags.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var tag in finding.Tags)
|
||||
{
|
||||
if (string.Equals(tag, "state:unknown", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double ResolveUnknownAgeDays(PolicyFinding finding)
|
||||
{
|
||||
var ageTag = TryGetTagValue(finding.Tags, "unknown-age-days:");
|
||||
if (!string.IsNullOrWhiteSpace(ageTag) &&
|
||||
double.TryParse(ageTag, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedAge) &&
|
||||
parsedAge >= 0)
|
||||
{
|
||||
return parsedAge;
|
||||
}
|
||||
|
||||
var sinceTag = TryGetTagValue(finding.Tags, "unknown-since:");
|
||||
if (string.IsNullOrWhiteSpace(sinceTag))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!DateTimeOffset.TryParse(sinceTag, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var since))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var observedTag = TryGetTagValue(finding.Tags, "observed-at:");
|
||||
if (!string.IsNullOrWhiteSpace(observedTag) &&
|
||||
DateTimeOffset.TryParse(observedTag, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var observed) &&
|
||||
observed > since)
|
||||
{
|
||||
return Math.Max(0, (observed - since).TotalDays);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string? ResolveTrustKey(PolicyFinding finding)
|
||||
{
|
||||
if (!finding.Tags.IsDefaultOrEmpty)
|
||||
{
|
||||
var tagged = TryGetTagValue(finding.Tags, "trust:");
|
||||
if (!string.IsNullOrWhiteSpace(tagged))
|
||||
{
|
||||
return tagged;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(finding.Source))
|
||||
{
|
||||
return finding.Source;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(finding.Vendor))
|
||||
{
|
||||
return finding.Vendor;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double ResolveTrustWeight(PolicyScoringConfig config, string? key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || config.TrustOverrides.IsEmpty)
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
return config.TrustOverrides.TryGetValue(key, out var weight) ? weight : 1.0;
|
||||
}
|
||||
|
||||
private static string? ResolveReachabilityKey(PolicyFinding finding)
|
||||
{
|
||||
if (finding.Tags.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var reachability = TryGetTagValue(finding.Tags, "reachability:");
|
||||
if (!string.IsNullOrWhiteSpace(reachability))
|
||||
{
|
||||
return reachability;
|
||||
}
|
||||
|
||||
var usage = TryGetTagValue(finding.Tags, "usage:");
|
||||
if (!string.IsNullOrWhiteSpace(usage))
|
||||
{
|
||||
return usage;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double ResolveReachabilityWeight(PolicyScoringConfig config, string? key, out string? resolvedKey)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(key) && config.ReachabilityBuckets.TryGetValue(key, out var weight))
|
||||
{
|
||||
resolvedKey = key;
|
||||
return weight;
|
||||
}
|
||||
|
||||
if (config.ReachabilityBuckets.TryGetValue("unknown", out var unknownWeight))
|
||||
{
|
||||
resolvedKey = "unknown";
|
||||
return unknownWeight;
|
||||
}
|
||||
|
||||
resolvedKey = key;
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
private static string? TryGetTagValue(ImmutableArray<string> tags, string prefix)
|
||||
{
|
||||
if (tags.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var value = tag[prefix.Length..].Trim();
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private readonly record struct ScoringComponents(
|
||||
double SeverityWeight,
|
||||
double TrustWeight,
|
||||
double ReachabilityWeight,
|
||||
double BaseScore,
|
||||
string? TrustKey,
|
||||
string? ReachabilityKey);
|
||||
|
||||
private readonly struct UnknownConfidenceResult
|
||||
{
|
||||
public UnknownConfidenceResult(double ageDays, double confidence, PolicyUnknownConfidenceBand band)
|
||||
{
|
||||
AgeDays = ageDays;
|
||||
Confidence = confidence;
|
||||
Band = band;
|
||||
}
|
||||
|
||||
public double AgeDays { get; }
|
||||
|
||||
public double Confidence { get; }
|
||||
|
||||
public PolicyUnknownConfidenceBand Band { get; }
|
||||
}
|
||||
|
||||
private static bool RuleMatches(PolicyRule rule, PolicyFinding finding)
|
||||
{
|
||||
if (!rule.Severities.IsDefaultOrEmpty && !rule.Severities.Contains(finding.Severity))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Matches(rule.Environments, finding.Environment))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Matches(rule.Sources, finding.Source))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Matches(rule.Vendors, finding.Vendor))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Matches(rule.Licenses, finding.License))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!RuleMatchCriteria(rule.Match, finding))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool Matches(ImmutableArray<string> ruleValues, string? candidate)
|
||||
{
|
||||
if (ruleValues.IsDefaultOrEmpty)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ruleValues.Contains(candidate, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool RuleMatchCriteria(PolicyRuleMatchCriteria criteria, PolicyFinding finding)
|
||||
{
|
||||
if (!criteria.Images.IsDefaultOrEmpty && !ContainsValue(criteria.Images, finding.Image, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!criteria.Repositories.IsDefaultOrEmpty && !ContainsValue(criteria.Repositories, finding.Repository, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!criteria.Packages.IsDefaultOrEmpty && !ContainsValue(criteria.Packages, finding.Package, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!criteria.Purls.IsDefaultOrEmpty && !ContainsValue(criteria.Purls, finding.Purl, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!criteria.Cves.IsDefaultOrEmpty && !ContainsValue(criteria.Cves, finding.Cve, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!criteria.Paths.IsDefaultOrEmpty && !ContainsValue(criteria.Paths, finding.Path, StringComparer.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!criteria.LayerDigests.IsDefaultOrEmpty && !ContainsValue(criteria.LayerDigests, finding.LayerDigest, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!criteria.UsedByEntrypoint.IsDefaultOrEmpty)
|
||||
{
|
||||
var match = false;
|
||||
foreach (var tag in criteria.UsedByEntrypoint)
|
||||
{
|
||||
if (finding.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
match = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!match)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ContainsValue(ImmutableArray<string> values, string? candidate, StringComparer comparer)
|
||||
{
|
||||
if (values.IsDefaultOrEmpty)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return values.Contains(candidate, comparer);
|
||||
}
|
||||
|
||||
private static PolicyVerdictStatus MapAction(PolicyAction action)
|
||||
=> action.Type switch
|
||||
{
|
||||
PolicyActionType.Block => PolicyVerdictStatus.Blocked,
|
||||
PolicyActionType.Ignore => PolicyVerdictStatus.Ignored,
|
||||
PolicyActionType.Warn => PolicyVerdictStatus.Warned,
|
||||
PolicyActionType.Defer => PolicyVerdictStatus.Deferred,
|
||||
PolicyActionType.Escalate => PolicyVerdictStatus.Escalated,
|
||||
PolicyActionType.RequireVex => PolicyVerdictStatus.RequiresVex,
|
||||
_ => PolicyVerdictStatus.Pass,
|
||||
};
|
||||
|
||||
private static string? BuildNotes(PolicyAction action)
|
||||
{
|
||||
if (action.Ignore is { } ignore && !string.IsNullOrWhiteSpace(ignore.Justification))
|
||||
{
|
||||
return ignore.Justification;
|
||||
}
|
||||
|
||||
if (action.Escalate is { } escalate && escalate.MinimumSeverity is { } severity)
|
||||
{
|
||||
return $"Escalate >= {severity}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? AppendNote(string? existing, string addition)
|
||||
=> string.IsNullOrWhiteSpace(existing) ? addition : string.Concat(existing, " | ", addition);
|
||||
}
|
||||
51
src/Policy/__Libraries/StellaOps.Policy/PolicyFinding.cs
Normal file
51
src/Policy/__Libraries/StellaOps.Policy/PolicyFinding.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyFinding(
|
||||
string FindingId,
|
||||
PolicySeverity Severity,
|
||||
string? Environment,
|
||||
string? Source,
|
||||
string? Vendor,
|
||||
string? License,
|
||||
string? Image,
|
||||
string? Repository,
|
||||
string? Package,
|
||||
string? Purl,
|
||||
string? Cve,
|
||||
string? Path,
|
||||
string? LayerDigest,
|
||||
ImmutableArray<string> Tags)
|
||||
{
|
||||
public static PolicyFinding Create(
|
||||
string findingId,
|
||||
PolicySeverity severity,
|
||||
string? environment = null,
|
||||
string? source = null,
|
||||
string? vendor = null,
|
||||
string? license = null,
|
||||
string? image = null,
|
||||
string? repository = null,
|
||||
string? package = null,
|
||||
string? purl = null,
|
||||
string? cve = null,
|
||||
string? path = null,
|
||||
string? layerDigest = null,
|
||||
ImmutableArray<string>? tags = null)
|
||||
=> new(
|
||||
findingId,
|
||||
severity,
|
||||
environment,
|
||||
source,
|
||||
vendor,
|
||||
license,
|
||||
image,
|
||||
repository,
|
||||
package,
|
||||
purl,
|
||||
cve,
|
||||
path,
|
||||
layerDigest,
|
||||
tags ?? ImmutableArray<string>.Empty);
|
||||
}
|
||||
28
src/Policy/__Libraries/StellaOps.Policy/PolicyIssue.cs
Normal file
28
src/Policy/__Libraries/StellaOps.Policy/PolicyIssue.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a validation or normalization issue discovered while processing a policy document.
|
||||
/// </summary>
|
||||
public sealed record PolicyIssue(string Code, string Message, PolicyIssueSeverity Severity, string Path)
|
||||
{
|
||||
public static PolicyIssue Error(string code, string message, string path)
|
||||
=> new(code, message, PolicyIssueSeverity.Error, path);
|
||||
|
||||
public static PolicyIssue Warning(string code, string message, string path)
|
||||
=> new(code, message, PolicyIssueSeverity.Warning, path);
|
||||
|
||||
public static PolicyIssue Info(string code, string message, string path)
|
||||
=> new(code, message, PolicyIssueSeverity.Info, path);
|
||||
|
||||
public PolicyIssue EnsurePath(string fallbackPath)
|
||||
=> string.IsNullOrWhiteSpace(Path) ? this with { Path = fallbackPath } : this;
|
||||
}
|
||||
|
||||
public enum PolicyIssueSeverity
|
||||
{
|
||||
Error,
|
||||
Warning,
|
||||
Info,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyPreviewRequest(
|
||||
string ImageDigest,
|
||||
ImmutableArray<PolicyFinding> Findings,
|
||||
ImmutableArray<PolicyVerdict> BaselineVerdicts,
|
||||
PolicySnapshot? SnapshotOverride = null,
|
||||
PolicySnapshotContent? ProposedPolicy = null);
|
||||
|
||||
public sealed record PolicyPreviewResponse(
|
||||
bool Success,
|
||||
string PolicyDigest,
|
||||
string? RevisionId,
|
||||
ImmutableArray<PolicyIssue> Issues,
|
||||
ImmutableArray<PolicyVerdictDiff> Diffs,
|
||||
int ChangedCount);
|
||||
142
src/Policy/__Libraries/StellaOps.Policy/PolicyPreviewService.cs
Normal file
142
src/Policy/__Libraries/StellaOps.Policy/PolicyPreviewService.cs
Normal file
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed class PolicyPreviewService
|
||||
{
|
||||
private readonly PolicySnapshotStore _snapshotStore;
|
||||
private readonly ILogger<PolicyPreviewService> _logger;
|
||||
|
||||
public PolicyPreviewService(PolicySnapshotStore snapshotStore, ILogger<PolicyPreviewService> logger)
|
||||
{
|
||||
_snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<PolicyPreviewResponse> PreviewAsync(PolicyPreviewRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var (snapshot, bindingIssues) = await ResolveSnapshotAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
_logger.LogWarning("Policy preview failed: snapshot unavailable or validation errors. Issues={Count}", bindingIssues.Length);
|
||||
return new PolicyPreviewResponse(false, string.Empty, null, bindingIssues, ImmutableArray<PolicyVerdictDiff>.Empty, 0);
|
||||
}
|
||||
|
||||
var projected = Evaluate(snapshot.Document, snapshot.ScoringConfig, request.Findings);
|
||||
var baseline = BuildBaseline(request.BaselineVerdicts, projected, snapshot.ScoringConfig);
|
||||
var diffs = BuildDiffs(baseline, projected);
|
||||
var changed = diffs.Count(static diff => diff.Changed);
|
||||
|
||||
_logger.LogDebug("Policy preview computed for {ImageDigest}. Changed={Changed}", request.ImageDigest, changed);
|
||||
|
||||
return new PolicyPreviewResponse(true, snapshot.Digest, snapshot.RevisionId, bindingIssues, diffs, changed);
|
||||
}
|
||||
|
||||
private async Task<(PolicySnapshot? Snapshot, ImmutableArray<PolicyIssue> Issues)> ResolveSnapshotAsync(PolicyPreviewRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ProposedPolicy is not null)
|
||||
{
|
||||
var binding = PolicyBinder.Bind(request.ProposedPolicy.Content, request.ProposedPolicy.Format);
|
||||
if (!binding.Success)
|
||||
{
|
||||
return (null, binding.Issues);
|
||||
}
|
||||
|
||||
var digest = PolicyDigest.Compute(binding.Document);
|
||||
var snapshot = new PolicySnapshot(
|
||||
request.SnapshotOverride?.RevisionNumber + 1 ?? 0,
|
||||
request.SnapshotOverride?.RevisionId ?? "preview",
|
||||
digest,
|
||||
DateTimeOffset.UtcNow,
|
||||
request.ProposedPolicy.Actor,
|
||||
request.ProposedPolicy.Format,
|
||||
binding.Document,
|
||||
binding.Issues,
|
||||
PolicyScoringConfig.Default);
|
||||
|
||||
return (snapshot, binding.Issues);
|
||||
}
|
||||
|
||||
if (request.SnapshotOverride is not null)
|
||||
{
|
||||
return (request.SnapshotOverride, ImmutableArray<PolicyIssue>.Empty);
|
||||
}
|
||||
|
||||
var latest = await _snapshotStore.GetLatestAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (latest is not null)
|
||||
{
|
||||
return (latest, ImmutableArray<PolicyIssue>.Empty);
|
||||
}
|
||||
|
||||
return (null, ImmutableArray.Create(PolicyIssue.Error("policy.preview.snapshot_missing", "No policy snapshot is available for preview.", "$")));
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyVerdict> Evaluate(PolicyDocument document, PolicyScoringConfig scoringConfig, ImmutableArray<PolicyFinding> findings)
|
||||
{
|
||||
if (findings.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<PolicyVerdict>.Empty;
|
||||
}
|
||||
|
||||
var results = ImmutableArray.CreateBuilder<PolicyVerdict>(findings.Length);
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
var verdict = PolicyEvaluation.EvaluateFinding(document, scoringConfig, finding);
|
||||
results.Add(verdict);
|
||||
}
|
||||
|
||||
return results.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, PolicyVerdict> BuildBaseline(ImmutableArray<PolicyVerdict> baseline, ImmutableArray<PolicyVerdict> projected, PolicyScoringConfig scoringConfig)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, PolicyVerdict>(StringComparer.Ordinal);
|
||||
if (!baseline.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var verdict in baseline)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(verdict.FindingId) && !builder.ContainsKey(verdict.FindingId))
|
||||
{
|
||||
builder.Add(verdict.FindingId, verdict);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var verdict in projected)
|
||||
{
|
||||
if (!builder.ContainsKey(verdict.FindingId))
|
||||
{
|
||||
builder.Add(verdict.FindingId, PolicyVerdict.CreateBaseline(verdict.FindingId, scoringConfig));
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyVerdictDiff> BuildDiffs(ImmutableDictionary<string, PolicyVerdict> baseline, ImmutableArray<PolicyVerdict> projected)
|
||||
{
|
||||
var diffs = ImmutableArray.CreateBuilder<PolicyVerdictDiff>(projected.Length);
|
||||
foreach (var verdict in projected.OrderBy(static v => v.FindingId, StringComparer.Ordinal))
|
||||
{
|
||||
var baseVerdict = baseline.TryGetValue(verdict.FindingId, out var existing)
|
||||
? existing
|
||||
: new PolicyVerdict(verdict.FindingId, PolicyVerdictStatus.Pass);
|
||||
|
||||
diffs.Add(new PolicyVerdictDiff(baseVerdict, verdict));
|
||||
}
|
||||
|
||||
return diffs.ToImmutable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public static class PolicySchemaResource
|
||||
{
|
||||
private const string SchemaResourceName = "StellaOps.Policy.Schemas.policy-schema@1.json";
|
||||
|
||||
public static Stream OpenSchemaStream()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var stream = assembly.GetManifestResourceStream(SchemaResourceName);
|
||||
if (stream is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to locate embedded schema resource '{SchemaResourceName}'.");
|
||||
}
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
public static string ReadSchemaJson()
|
||||
{
|
||||
using var stream = OpenSchemaStream();
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyScoringConfig(
|
||||
string Version,
|
||||
ImmutableDictionary<PolicySeverity, double> SeverityWeights,
|
||||
double QuietPenalty,
|
||||
double WarnPenalty,
|
||||
double IgnorePenalty,
|
||||
ImmutableDictionary<string, double> TrustOverrides,
|
||||
ImmutableDictionary<string, double> ReachabilityBuckets,
|
||||
PolicyUnknownConfidenceConfig UnknownConfidence)
|
||||
{
|
||||
public static string BaselineVersion => "1.0";
|
||||
|
||||
public static PolicyScoringConfig Default { get; } = PolicyScoringConfigBinder.LoadDefault();
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Json.Schema;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyScoringBindingResult(
|
||||
bool Success,
|
||||
PolicyScoringConfig? Config,
|
||||
ImmutableArray<PolicyIssue> Issues);
|
||||
|
||||
public static class PolicyScoringConfigBinder
|
||||
{
|
||||
private const string DefaultResourceName = "StellaOps.Policy.Schemas.policy-scoring-default.json";
|
||||
|
||||
private static readonly JsonSchema ScoringSchema = PolicyScoringSchema.Schema;
|
||||
|
||||
private static readonly ImmutableDictionary<string, double> DefaultReachabilityBuckets = CreateDefaultReachabilityBuckets();
|
||||
|
||||
private static readonly PolicyUnknownConfidenceConfig DefaultUnknownConfidence = CreateDefaultUnknownConfidence();
|
||||
|
||||
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
public static PolicyScoringConfig LoadDefault()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
using var stream = assembly.GetManifestResourceStream(DefaultResourceName)
|
||||
?? throw new InvalidOperationException($"Embedded resource '{DefaultResourceName}' not found.");
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
var json = reader.ReadToEnd();
|
||||
var binding = Bind(json, PolicyDocumentFormat.Json);
|
||||
if (!binding.Success || binding.Config is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to load default policy scoring configuration.");
|
||||
}
|
||||
|
||||
return binding.Config;
|
||||
}
|
||||
|
||||
public static PolicyScoringBindingResult Bind(string content, PolicyDocumentFormat format)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
var issue = PolicyIssue.Error("scoring.empty", "Scoring configuration content is empty.", "$");
|
||||
return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var root = Parse(content, format);
|
||||
if (root is not JsonObject obj)
|
||||
{
|
||||
var issue = PolicyIssue.Error("scoring.invalid", "Scoring configuration must be a JSON object.", "$");
|
||||
return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue));
|
||||
}
|
||||
|
||||
var issues = ImmutableArray.CreateBuilder<PolicyIssue>();
|
||||
var schemaIssues = ValidateAgainstSchema(root);
|
||||
issues.AddRange(schemaIssues);
|
||||
if (schemaIssues.Any(static issue => issue.Severity == PolicyIssueSeverity.Error))
|
||||
{
|
||||
return new PolicyScoringBindingResult(false, null, issues.ToImmutable());
|
||||
}
|
||||
|
||||
var config = BuildConfig(obj, issues);
|
||||
var hasErrors = issues.Any(issue => issue.Severity == PolicyIssueSeverity.Error);
|
||||
return new PolicyScoringBindingResult(!hasErrors, config, issues.ToImmutable());
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var issue = PolicyIssue.Error("scoring.parse.json", $"Failed to parse scoring JSON: {ex.Message}", "$");
|
||||
return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue));
|
||||
}
|
||||
catch (YamlDotNet.Core.YamlException ex)
|
||||
{
|
||||
var issue = PolicyIssue.Error("scoring.parse.yaml", $"Failed to parse scoring YAML: {ex.Message}", "$");
|
||||
return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue));
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode? Parse(string content, PolicyDocumentFormat format)
|
||||
{
|
||||
return format switch
|
||||
{
|
||||
PolicyDocumentFormat.Json => JsonNode.Parse(content, new JsonNodeOptions { PropertyNameCaseInsensitive = true }),
|
||||
PolicyDocumentFormat.Yaml => ConvertYamlToJsonNode(content),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unsupported scoring configuration format."),
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonNode? ConvertYamlToJsonNode(string content)
|
||||
{
|
||||
var yamlObject = YamlDeserializer.Deserialize<object?>(content);
|
||||
return PolicyBinderUtilities.ConvertYamlObject(yamlObject);
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyIssue> ValidateAgainstSchema(JsonNode root)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(root.ToJsonString(new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
}));
|
||||
|
||||
var result = ScoringSchema.Evaluate(document.RootElement, new EvaluationOptions
|
||||
{
|
||||
OutputFormat = OutputFormat.List,
|
||||
RequireFormatValidation = true,
|
||||
});
|
||||
|
||||
if (result.IsValid)
|
||||
{
|
||||
return ImmutableArray<PolicyIssue>.Empty;
|
||||
}
|
||||
|
||||
var issues = ImmutableArray.CreateBuilder<PolicyIssue>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
CollectSchemaIssues(result, issues, seen);
|
||||
return issues.ToImmutable();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return ImmutableArray.Create(PolicyIssue.Error("scoring.schema.normalize", $"Failed to normalize scoring configuration for schema validation: {ex.Message}", "$"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectSchemaIssues(EvaluationResults result, ImmutableArray<PolicyIssue>.Builder issues, HashSet<string> seen)
|
||||
{
|
||||
if (result.Errors is { Count: > 0 })
|
||||
{
|
||||
foreach (var pair in result.Errors)
|
||||
{
|
||||
var keyword = SanitizeKeyword(pair.Key);
|
||||
var path = ConvertPointerToPath(result.InstanceLocation?.ToString() ?? "#");
|
||||
var message = pair.Value ?? "Schema violation.";
|
||||
var key = $"{path}|{keyword}|{message}";
|
||||
if (seen.Add(key))
|
||||
{
|
||||
issues.Add(PolicyIssue.Error($"scoring.schema.{keyword}", message, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Details is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var detail in result.Details)
|
||||
{
|
||||
CollectSchemaIssues(detail, issues, seen);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ConvertPointerToPath(string pointer)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pointer) || pointer == "#")
|
||||
{
|
||||
return "$";
|
||||
}
|
||||
|
||||
if (pointer[0] == '#')
|
||||
{
|
||||
pointer = pointer.Length > 1 ? pointer[1..] : string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(pointer))
|
||||
{
|
||||
return "$";
|
||||
}
|
||||
|
||||
var segments = pointer.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
var builder = new StringBuilder("$");
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var unescaped = segment.Replace("~1", "/").Replace("~0", "~");
|
||||
if (int.TryParse(unescaped, out var index))
|
||||
{
|
||||
builder.Append('[').Append(index).Append(']');
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append('.').Append(unescaped);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string SanitizeKeyword(string keyword)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(keyword.Length);
|
||||
foreach (var ch in keyword)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch))
|
||||
{
|
||||
builder.Append(char.ToLowerInvariant(ch));
|
||||
}
|
||||
else if (ch is '.' or '_' or '-')
|
||||
{
|
||||
builder.Append(ch);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append('_');
|
||||
}
|
||||
}
|
||||
|
||||
return builder.Length == 0 ? "unknown" : builder.ToString();
|
||||
}
|
||||
|
||||
private static PolicyScoringConfig BuildConfig(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
var version = ReadString(obj, "version", issues, required: true) ?? PolicyScoringConfig.BaselineVersion;
|
||||
|
||||
var severityWeights = ReadSeverityWeights(obj, issues);
|
||||
var quietPenalty = ReadDouble(obj, "quietPenalty", issues, defaultValue: 45);
|
||||
var warnPenalty = ReadDouble(obj, "warnPenalty", issues, defaultValue: 15);
|
||||
var ignorePenalty = ReadDouble(obj, "ignorePenalty", issues, defaultValue: 35);
|
||||
var trustOverrides = ReadTrustOverrides(obj, issues);
|
||||
var reachabilityBuckets = ReadReachabilityBuckets(obj, issues);
|
||||
var unknownConfidence = ReadUnknownConfidence(obj, issues);
|
||||
|
||||
return new PolicyScoringConfig(
|
||||
version,
|
||||
severityWeights,
|
||||
quietPenalty,
|
||||
warnPenalty,
|
||||
ignorePenalty,
|
||||
trustOverrides,
|
||||
reachabilityBuckets,
|
||||
unknownConfidence);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, double> CreateDefaultReachabilityBuckets()
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
builder["entrypoint"] = 1.0;
|
||||
builder["direct"] = 0.85;
|
||||
builder["indirect"] = 0.6;
|
||||
builder["runtime"] = 0.45;
|
||||
builder["unreachable"] = 0.25;
|
||||
builder["unknown"] = 0.5;
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static PolicyUnknownConfidenceConfig CreateDefaultUnknownConfidence()
|
||||
{
|
||||
var bands = ImmutableArray.Create(
|
||||
new PolicyUnknownConfidenceBand("high", 0.65, "Fresh unknowns with recent telemetry."),
|
||||
new PolicyUnknownConfidenceBand("medium", 0.35, "Unknowns aging toward action required."),
|
||||
new PolicyUnknownConfidenceBand("low", 0.0, "Stale unknowns that must be triaged."));
|
||||
return new PolicyUnknownConfidenceConfig(0.8, 0.05, 0.2, bands);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, double> ReadReachabilityBuckets(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (!obj.TryGetPropertyValue("reachabilityBuckets", out var node))
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.reachability.default", "reachabilityBuckets not specified; defaulting to baseline weights.", "$.reachabilityBuckets"));
|
||||
return DefaultReachabilityBuckets;
|
||||
}
|
||||
|
||||
if (node is not JsonObject bucketsObj)
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.reachability.type", "reachabilityBuckets must be an object.", "$.reachabilityBuckets"));
|
||||
return DefaultReachabilityBuckets;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in bucketsObj)
|
||||
{
|
||||
if (pair.Value is null)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.reachability.null", $"Bucket '{pair.Key}' is null; defaulting to 0.", $"$.reachabilityBuckets.{pair.Key}"));
|
||||
builder[pair.Key] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = ExtractDouble(pair.Value, issues, $"$.reachabilityBuckets.{pair.Key}");
|
||||
builder[pair.Key] = value;
|
||||
}
|
||||
|
||||
if (builder.Count == 0)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.reachability.empty", "No reachability buckets defined; using defaults.", "$.reachabilityBuckets"));
|
||||
return DefaultReachabilityBuckets;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static PolicyUnknownConfidenceConfig ReadUnknownConfidence(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (!obj.TryGetPropertyValue("unknownConfidence", out var node))
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.default", "unknownConfidence not specified; defaulting to baseline decay settings.", "$.unknownConfidence"));
|
||||
return DefaultUnknownConfidence;
|
||||
}
|
||||
|
||||
if (node is not JsonObject configObj)
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.unknown.type", "unknownConfidence must be an object.", "$.unknownConfidence"));
|
||||
return DefaultUnknownConfidence;
|
||||
}
|
||||
|
||||
var initial = DefaultUnknownConfidence.Initial;
|
||||
if (configObj.TryGetPropertyValue("initial", out var initialNode))
|
||||
{
|
||||
initial = ExtractDouble(initialNode, issues, "$.unknownConfidence.initial");
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.initial.default", "initial not specified; using baseline value.", "$.unknownConfidence.initial"));
|
||||
}
|
||||
|
||||
var decay = DefaultUnknownConfidence.DecayPerDay;
|
||||
if (configObj.TryGetPropertyValue("decayPerDay", out var decayNode))
|
||||
{
|
||||
decay = ExtractDouble(decayNode, issues, "$.unknownConfidence.decayPerDay");
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.decay.default", "decayPerDay not specified; using baseline value.", "$.unknownConfidence.decayPerDay"));
|
||||
}
|
||||
|
||||
var floor = DefaultUnknownConfidence.Floor;
|
||||
if (configObj.TryGetPropertyValue("floor", out var floorNode))
|
||||
{
|
||||
floor = ExtractDouble(floorNode, issues, "$.unknownConfidence.floor");
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.floor.default", "floor not specified; using baseline value.", "$.unknownConfidence.floor"));
|
||||
}
|
||||
|
||||
var bands = ReadConfidenceBands(configObj, issues);
|
||||
if (bands.IsDefaultOrEmpty)
|
||||
{
|
||||
bands = DefaultUnknownConfidence.Bands;
|
||||
}
|
||||
|
||||
if (initial < 0 || initial > 1)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.initial.range", "initial confidence should be between 0 and 1. Clamping to valid range.", "$.unknownConfidence.initial"));
|
||||
initial = Math.Clamp(initial, 0, 1);
|
||||
}
|
||||
|
||||
if (decay < 0 || decay > 1)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.decay.range", "decayPerDay should be between 0 and 1. Clamping to valid range.", "$.unknownConfidence.decayPerDay"));
|
||||
decay = Math.Clamp(decay, 0, 1);
|
||||
}
|
||||
|
||||
if (floor < 0 || floor > 1)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.floor.range", "floor should be between 0 and 1. Clamping to valid range.", "$.unknownConfidence.floor"));
|
||||
floor = Math.Clamp(floor, 0, 1);
|
||||
}
|
||||
|
||||
return new PolicyUnknownConfidenceConfig(initial, decay, floor, bands);
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyUnknownConfidenceBand> ReadConfidenceBands(JsonObject configObj, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (!configObj.TryGetPropertyValue("bands", out var node))
|
||||
{
|
||||
return ImmutableArray<PolicyUnknownConfidenceBand>.Empty;
|
||||
}
|
||||
|
||||
if (node is not JsonArray array)
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.unknown.bands.type", "unknownConfidence.bands must be an array.", "$.unknownConfidence.bands"));
|
||||
return ImmutableArray<PolicyUnknownConfidenceBand>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<PolicyUnknownConfidenceBand>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var index = 0; index < array.Count; index++)
|
||||
{
|
||||
var element = array[index];
|
||||
if (element is not JsonObject bandObj)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.band.type", "Band entry must be an object.", $"$.unknownConfidence.bands[{index}]"));
|
||||
continue;
|
||||
}
|
||||
|
||||
string? name = null;
|
||||
if (bandObj.TryGetPropertyValue("name", out var nameNode) && nameNode is JsonValue nameValue && nameValue.TryGetValue(out string? text))
|
||||
{
|
||||
name = text?.Trim();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.unknown.band.name", "Band entry requires a non-empty 'name'.", $"$.unknownConfidence.bands[{index}].name"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.Add(name))
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.band.duplicate", $"Duplicate band '{name}' encountered.", $"$.unknownConfidence.bands[{index}].name"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!bandObj.TryGetPropertyValue("min", out var minNode))
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.unknown.band.min", $"Band '{name}' is missing 'min'.", $"$.unknownConfidence.bands[{index}].min"));
|
||||
continue;
|
||||
}
|
||||
|
||||
var min = ExtractDouble(minNode, issues, $"$.unknownConfidence.bands[{index}].min");
|
||||
if (min < 0 || min > 1)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.band.range", $"Band '{name}' min should be between 0 and 1. Clamping to valid range.", $"$.unknownConfidence.bands[{index}].min"));
|
||||
min = Math.Clamp(min, 0, 1);
|
||||
}
|
||||
|
||||
string? description = null;
|
||||
if (bandObj.TryGetPropertyValue("description", out var descriptionNode) && descriptionNode is JsonValue descriptionValue && descriptionValue.TryGetValue(out string? descriptionText))
|
||||
{
|
||||
description = descriptionText?.Trim();
|
||||
}
|
||||
|
||||
builder.Add(new PolicyUnknownConfidenceBand(name, min, description));
|
||||
}
|
||||
|
||||
if (builder.Count == 0)
|
||||
{
|
||||
return ImmutableArray<PolicyUnknownConfidenceBand>.Empty;
|
||||
}
|
||||
|
||||
return builder.ToImmutable()
|
||||
.OrderByDescending(static band => band.Min)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<PolicySeverity, double> ReadSeverityWeights(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (!obj.TryGetPropertyValue("severityWeights", out var node) || node is not JsonObject severityObj)
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.severityWeights.missing", "severityWeights section is required.", "$.severityWeights"));
|
||||
return ImmutableDictionary<PolicySeverity, double>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<PolicySeverity, double>();
|
||||
foreach (var severity in Enum.GetValues<PolicySeverity>())
|
||||
{
|
||||
var key = severity.ToString();
|
||||
if (!severityObj.TryGetPropertyValue(key, out var valueNode))
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.severityWeights.default", $"Severity '{key}' not specified; defaulting to 0.", $"$.severityWeights.{key}"));
|
||||
builder[severity] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = ExtractDouble(valueNode, issues, $"$.severityWeights.{key}");
|
||||
builder[severity] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static double ReadDouble(JsonObject obj, string property, ImmutableArray<PolicyIssue>.Builder issues, double defaultValue)
|
||||
{
|
||||
if (!obj.TryGetPropertyValue(property, out var node))
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.numeric.default", $"{property} not specified; defaulting to {defaultValue:0.##}.", $"$.{property}"));
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return ExtractDouble(node, issues, $"$.{property}");
|
||||
}
|
||||
|
||||
private static double ExtractDouble(JsonNode? node, ImmutableArray<PolicyIssue>.Builder issues, string path)
|
||||
{
|
||||
if (node is null)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.numeric.null", $"Value at {path} missing; defaulting to 0.", path));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (node is JsonValue value)
|
||||
{
|
||||
if (value.TryGetValue(out double number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
|
||||
if (value.TryGetValue(out string? text) && double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
}
|
||||
|
||||
issues.Add(PolicyIssue.Error("scoring.numeric.invalid", $"Value at {path} is not numeric.", path));
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, double> ReadTrustOverrides(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (!obj.TryGetPropertyValue("trustOverrides", out var node) || node is not JsonObject trustObj)
|
||||
{
|
||||
return ImmutableDictionary<string, double>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in trustObj)
|
||||
{
|
||||
var value = ExtractDouble(pair.Value, issues, $"$.trustOverrides.{pair.Key}");
|
||||
builder[pair.Key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonObject obj, string property, ImmutableArray<PolicyIssue>.Builder issues, bool required)
|
||||
{
|
||||
if (!obj.TryGetPropertyValue(property, out var node) || node is null)
|
||||
{
|
||||
if (required)
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.string.missing", $"{property} is required.", $"$.{property}"));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node is JsonValue value && value.TryGetValue(out string? text))
|
||||
{
|
||||
return text?.Trim();
|
||||
}
|
||||
|
||||
issues.Add(PolicyIssue.Error("scoring.string.invalid", $"{property} must be a string.", $"$.{property}"));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PolicyBinderUtilities
|
||||
{
|
||||
public static JsonNode? ConvertYamlObject(object? value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null:
|
||||
return null;
|
||||
case string s when bool.TryParse(s, out var boolValue):
|
||||
return JsonValue.Create(boolValue);
|
||||
case string s:
|
||||
return JsonValue.Create(s);
|
||||
case bool b:
|
||||
return JsonValue.Create(b);
|
||||
case sbyte or byte or short or ushort or int or uint or long or ulong or float or double or decimal:
|
||||
return JsonValue.Create(Convert.ToDouble(value, CultureInfo.InvariantCulture));
|
||||
case IDictionary dictionary:
|
||||
{
|
||||
var obj = new JsonObject();
|
||||
foreach (DictionaryEntry entry in dictionary)
|
||||
{
|
||||
if (entry.Key is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
obj[entry.Key.ToString()!] = ConvertYamlObject(entry.Value);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
case IEnumerable enumerable:
|
||||
{
|
||||
var array = new JsonArray();
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
array.Add(ConvertYamlObject(item));
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
default:
|
||||
return JsonValue.Create(value.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public static class PolicyScoringConfigDigest
|
||||
{
|
||||
public static string Compute(PolicyScoringConfig config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
|
||||
{
|
||||
SkipValidation = true,
|
||||
}))
|
||||
{
|
||||
WriteConfig(writer, config);
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(buffer.WrittenSpan);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void WriteConfig(Utf8JsonWriter writer, PolicyScoringConfig config)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("version", config.Version);
|
||||
|
||||
writer.WritePropertyName("severityWeights");
|
||||
writer.WriteStartObject();
|
||||
foreach (var severity in Enum.GetValues<PolicySeverity>())
|
||||
{
|
||||
var key = severity.ToString();
|
||||
var value = config.SeverityWeights.TryGetValue(severity, out var weight) ? weight : 0;
|
||||
writer.WriteNumber(key, value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
|
||||
writer.WriteNumber("quietPenalty", config.QuietPenalty);
|
||||
writer.WriteNumber("warnPenalty", config.WarnPenalty);
|
||||
writer.WriteNumber("ignorePenalty", config.IgnorePenalty);
|
||||
|
||||
if (!config.TrustOverrides.IsEmpty)
|
||||
{
|
||||
writer.WritePropertyName("trustOverrides");
|
||||
writer.WriteStartObject();
|
||||
foreach (var pair in config.TrustOverrides.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteNumber(pair.Key, pair.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
if (!config.ReachabilityBuckets.IsEmpty)
|
||||
{
|
||||
writer.WritePropertyName("reachabilityBuckets");
|
||||
writer.WriteStartObject();
|
||||
foreach (var pair in config.ReachabilityBuckets.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteNumber(pair.Key, pair.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WritePropertyName("unknownConfidence");
|
||||
writer.WriteStartObject();
|
||||
writer.WriteNumber("initial", config.UnknownConfidence.Initial);
|
||||
writer.WriteNumber("decayPerDay", config.UnknownConfidence.DecayPerDay);
|
||||
writer.WriteNumber("floor", config.UnknownConfidence.Floor);
|
||||
|
||||
if (!config.UnknownConfidence.Bands.IsDefaultOrEmpty)
|
||||
{
|
||||
writer.WritePropertyName("bands");
|
||||
writer.WriteStartArray();
|
||||
foreach (var band in config.UnknownConfidence.Bands
|
||||
.OrderByDescending(static b => b.Min)
|
||||
.ThenBy(static b => b.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", band.Name);
|
||||
writer.WriteNumber("min", band.Min);
|
||||
if (!string.IsNullOrWhiteSpace(band.Description))
|
||||
{
|
||||
writer.WriteString("description", band.Description);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using Json.Schema;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public static class PolicyScoringSchema
|
||||
{
|
||||
private const string SchemaResourceName = "StellaOps.Policy.Schemas.policy-scoring-schema@1.json";
|
||||
|
||||
private static readonly Lazy<JsonSchema> CachedSchema = new(LoadSchema, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
public static JsonSchema Schema => CachedSchema.Value;
|
||||
|
||||
private static JsonSchema LoadSchema()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
using var stream = assembly.GetManifestResourceStream(SchemaResourceName)
|
||||
?? throw new InvalidOperationException($"Embedded resource '{SchemaResourceName}' was not found.");
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
var schemaJson = reader.ReadToEnd();
|
||||
return JsonSchema.FromText(schemaJson);
|
||||
}
|
||||
}
|
||||
29
src/Policy/__Libraries/StellaOps.Policy/PolicySnapshot.cs
Normal file
29
src/Policy/__Libraries/StellaOps.Policy/PolicySnapshot.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicySnapshot(
|
||||
long RevisionNumber,
|
||||
string RevisionId,
|
||||
string Digest,
|
||||
DateTimeOffset CreatedAt,
|
||||
string? CreatedBy,
|
||||
PolicyDocumentFormat Format,
|
||||
PolicyDocument Document,
|
||||
ImmutableArray<PolicyIssue> Issues,
|
||||
PolicyScoringConfig ScoringConfig);
|
||||
|
||||
public sealed record PolicySnapshotContent(
|
||||
string Content,
|
||||
PolicyDocumentFormat Format,
|
||||
string? Actor,
|
||||
string? Source,
|
||||
string? Description);
|
||||
|
||||
public sealed record PolicySnapshotSaveResult(
|
||||
bool Success,
|
||||
bool Created,
|
||||
string Digest,
|
||||
PolicySnapshot? Snapshot,
|
||||
PolicyBindingResult BindingResult);
|
||||
101
src/Policy/__Libraries/StellaOps.Policy/PolicySnapshotStore.cs
Normal file
101
src/Policy/__Libraries/StellaOps.Policy/PolicySnapshotStore.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed class PolicySnapshotStore
|
||||
{
|
||||
private readonly IPolicySnapshotRepository _snapshotRepository;
|
||||
private readonly IPolicyAuditRepository _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PolicySnapshotStore> _logger;
|
||||
private readonly SemaphoreSlim _mutex = new(1, 1);
|
||||
|
||||
public PolicySnapshotStore(
|
||||
IPolicySnapshotRepository snapshotRepository,
|
||||
IPolicyAuditRepository auditRepository,
|
||||
TimeProvider? timeProvider,
|
||||
ILogger<PolicySnapshotStore> logger)
|
||||
{
|
||||
_snapshotRepository = snapshotRepository ?? throw new ArgumentNullException(nameof(snapshotRepository));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<PolicySnapshotSaveResult> SaveAsync(PolicySnapshotContent content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (content is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(content));
|
||||
}
|
||||
|
||||
var bindingResult = PolicyBinder.Bind(content.Content, content.Format);
|
||||
if (!bindingResult.Success)
|
||||
{
|
||||
_logger.LogWarning("Policy snapshot rejected due to validation errors (Format: {Format})", content.Format);
|
||||
return new PolicySnapshotSaveResult(false, false, string.Empty, null, bindingResult);
|
||||
}
|
||||
|
||||
var digest = PolicyDigest.Compute(bindingResult.Document);
|
||||
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var latest = await _snapshotRepository.GetLatestAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (latest is not null && string.Equals(latest.Digest, digest, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogInformation("Policy snapshot unchanged; digest {Digest} matches revision {RevisionId}", digest, latest.RevisionId);
|
||||
return new PolicySnapshotSaveResult(true, false, digest, latest, bindingResult);
|
||||
}
|
||||
|
||||
var revisionNumber = (latest?.RevisionNumber ?? 0) + 1;
|
||||
var revisionId = $"rev-{revisionNumber}";
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var scoringConfig = PolicyScoringConfig.Default;
|
||||
|
||||
var snapshot = new PolicySnapshot(
|
||||
revisionNumber,
|
||||
revisionId,
|
||||
digest,
|
||||
createdAt,
|
||||
content.Actor,
|
||||
content.Format,
|
||||
bindingResult.Document,
|
||||
bindingResult.Issues,
|
||||
scoringConfig);
|
||||
|
||||
await _snapshotRepository.AddAsync(snapshot, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var auditMessage = content.Description ?? "Policy snapshot created";
|
||||
var auditEntry = new PolicyAuditEntry(
|
||||
Guid.NewGuid(),
|
||||
createdAt,
|
||||
"snapshot.created",
|
||||
revisionId,
|
||||
digest,
|
||||
content.Actor,
|
||||
auditMessage);
|
||||
|
||||
await _auditRepository.AddAsync(auditEntry, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Policy snapshot saved. Revision {RevisionId}, digest {Digest}, issues {IssueCount}",
|
||||
revisionId,
|
||||
digest,
|
||||
bindingResult.Issues.Length);
|
||||
|
||||
return new PolicySnapshotSaveResult(true, true, digest, snapshot, bindingResult);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default)
|
||||
=> _snapshotRepository.GetLatestAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyUnknownConfidenceConfig(
|
||||
double Initial,
|
||||
double DecayPerDay,
|
||||
double Floor,
|
||||
ImmutableArray<PolicyUnknownConfidenceBand> Bands)
|
||||
{
|
||||
public double Clamp(double value)
|
||||
=> Math.Clamp(value, Floor, 1.0);
|
||||
|
||||
public PolicyUnknownConfidenceBand ResolveBand(double value)
|
||||
{
|
||||
if (Bands.IsDefaultOrEmpty)
|
||||
{
|
||||
return PolicyUnknownConfidenceBand.Default;
|
||||
}
|
||||
|
||||
foreach (var band in Bands)
|
||||
{
|
||||
if (value >= band.Min)
|
||||
{
|
||||
return band;
|
||||
}
|
||||
}
|
||||
|
||||
return Bands[Bands.Length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PolicyUnknownConfidenceBand(string Name, double Min, string? Description = null)
|
||||
{
|
||||
public static PolicyUnknownConfidenceBand Default { get; } = new("unspecified", 0, null);
|
||||
}
|
||||
242
src/Policy/__Libraries/StellaOps.Policy/PolicyValidationCli.cs
Normal file
242
src/Policy/__Libraries/StellaOps.Policy/PolicyValidationCli.cs
Normal file
@@ -0,0 +1,242 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyValidationCliOptions
|
||||
{
|
||||
public IReadOnlyList<string> Inputs { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Writes machine-readable JSON instead of human-formatted text.
|
||||
/// </summary>
|
||||
public bool OutputJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When enabled, warnings cause a non-zero exit code.
|
||||
/// </summary>
|
||||
public bool Strict { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PolicyValidationFileResult(
|
||||
string Path,
|
||||
PolicyBindingResult BindingResult,
|
||||
PolicyDiagnosticsReport Diagnostics);
|
||||
|
||||
public sealed class PolicyValidationCli
|
||||
{
|
||||
private readonly TextWriter _output;
|
||||
private readonly TextWriter _error;
|
||||
|
||||
public PolicyValidationCli(TextWriter? output = null, TextWriter? error = null)
|
||||
{
|
||||
_output = output ?? Console.Out;
|
||||
_error = error ?? Console.Error;
|
||||
}
|
||||
|
||||
public async Task<int> RunAsync(PolicyValidationCliOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
if (options.Inputs.Count == 0)
|
||||
{
|
||||
await _error.WriteLineAsync("No input files provided. Supply one or more policy file paths.");
|
||||
return 64; // EX_USAGE
|
||||
}
|
||||
|
||||
var results = new List<PolicyValidationFileResult>();
|
||||
foreach (var input in options.Inputs)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var resolvedPaths = ResolveInput(input);
|
||||
if (resolvedPaths.Count == 0)
|
||||
{
|
||||
await _error.WriteLineAsync($"No files matched '{input}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var path in resolvedPaths)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var format = PolicySchema.DetectFormat(path);
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
var bindingResult = PolicyBinder.Bind(content, format);
|
||||
var diagnostics = PolicyDiagnostics.Create(bindingResult);
|
||||
|
||||
results.Add(new PolicyValidationFileResult(path, bindingResult, diagnostics));
|
||||
}
|
||||
}
|
||||
|
||||
if (results.Count == 0)
|
||||
{
|
||||
await _error.WriteLineAsync("No files were processed.");
|
||||
return 65; // EX_DATAERR
|
||||
}
|
||||
|
||||
if (options.OutputJson)
|
||||
{
|
||||
WriteJson(results);
|
||||
}
|
||||
else
|
||||
{
|
||||
await WriteTextAsync(results, cancellationToken);
|
||||
}
|
||||
|
||||
var hasErrors = results.Any(static result => !result.BindingResult.Success);
|
||||
var hasWarnings = results.Any(static result => result.BindingResult.Issues.Any(static issue => issue.Severity == PolicyIssueSeverity.Warning));
|
||||
|
||||
if (hasErrors)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (options.Strict && hasWarnings)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async Task WriteTextAsync(IReadOnlyList<PolicyValidationFileResult> results, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var result in results)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var relativePath = MakeRelative(result.Path);
|
||||
await _output.WriteLineAsync($"{relativePath} [{result.BindingResult.Format}]");
|
||||
|
||||
if (result.BindingResult.Issues.Length == 0)
|
||||
{
|
||||
await _output.WriteLineAsync(" OK");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var issue in result.BindingResult.Issues)
|
||||
{
|
||||
var severity = issue.Severity.ToString().ToUpperInvariant().PadRight(7);
|
||||
await _output.WriteLineAsync($" {severity} {issue.Path} :: {issue.Message} ({issue.Code})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteJson(IReadOnlyList<PolicyValidationFileResult> results)
|
||||
{
|
||||
var payload = results.Select(static result => new
|
||||
{
|
||||
path = result.Path,
|
||||
format = result.BindingResult.Format.ToString().ToLowerInvariant(),
|
||||
success = result.BindingResult.Success,
|
||||
issues = result.BindingResult.Issues.Select(static issue => new
|
||||
{
|
||||
code = issue.Code,
|
||||
message = issue.Message,
|
||||
severity = issue.Severity.ToString().ToLowerInvariant(),
|
||||
path = issue.Path,
|
||||
}),
|
||||
diagnostics = new
|
||||
{
|
||||
version = result.Diagnostics.Version,
|
||||
ruleCount = result.Diagnostics.RuleCount,
|
||||
errorCount = result.Diagnostics.ErrorCount,
|
||||
warningCount = result.Diagnostics.WarningCount,
|
||||
generatedAt = result.Diagnostics.GeneratedAt,
|
||||
recommendations = result.Diagnostics.Recommendations,
|
||||
},
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
});
|
||||
_output.WriteLine(json);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveInput(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var expanded = Environment.ExpandEnvironmentVariables(input.Trim());
|
||||
if (File.Exists(expanded))
|
||||
{
|
||||
return new[] { Path.GetFullPath(expanded) };
|
||||
}
|
||||
|
||||
if (Directory.Exists(expanded))
|
||||
{
|
||||
return Directory.EnumerateFiles(expanded, "*.*", SearchOption.TopDirectoryOnly)
|
||||
.Where(static path => MatchesPolicyExtension(path))
|
||||
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(Path.GetFullPath)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(expanded);
|
||||
var searchPattern = Path.GetFileName(expanded);
|
||||
|
||||
if (string.IsNullOrEmpty(searchPattern))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(directory))
|
||||
{
|
||||
directory = ".";
|
||||
}
|
||||
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return Directory.EnumerateFiles(directory, searchPattern, SearchOption.TopDirectoryOnly)
|
||||
.Where(static path => MatchesPolicyExtension(path))
|
||||
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(Path.GetFullPath)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool MatchesPolicyExtension(string path)
|
||||
{
|
||||
var extension = Path.GetExtension(path);
|
||||
return extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase)
|
||||
|| extension.Equals(".yml", StringComparison.OrdinalIgnoreCase)
|
||||
|| extension.Equals(".json", StringComparison.OrdinalIgnoreCase)
|
||||
|| extension.Equals(".stella", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string MakeRelative(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
var current = Directory.GetCurrentDirectory();
|
||||
if (fullPath.StartsWith(current, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return fullPath[current.Length..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/Policy/__Libraries/StellaOps.Policy/PolicyVerdict.cs
Normal file
112
src/Policy/__Libraries/StellaOps.Policy/PolicyVerdict.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public enum PolicyVerdictStatus
|
||||
{
|
||||
Pass,
|
||||
Blocked,
|
||||
Ignored,
|
||||
Warned,
|
||||
Deferred,
|
||||
Escalated,
|
||||
RequiresVex,
|
||||
}
|
||||
|
||||
public sealed record PolicyVerdict(
|
||||
string FindingId,
|
||||
PolicyVerdictStatus Status,
|
||||
string? RuleName = null,
|
||||
string? RuleAction = null,
|
||||
string? Notes = null,
|
||||
double Score = 0,
|
||||
string ConfigVersion = "1.0",
|
||||
ImmutableDictionary<string, double>? Inputs = null,
|
||||
string? QuietedBy = null,
|
||||
bool Quiet = false,
|
||||
double? UnknownConfidence = null,
|
||||
string? ConfidenceBand = null,
|
||||
double? UnknownAgeDays = null,
|
||||
string? SourceTrust = null,
|
||||
string? Reachability = null)
|
||||
{
|
||||
public static PolicyVerdict CreateBaseline(string findingId, PolicyScoringConfig scoringConfig)
|
||||
{
|
||||
var inputs = ImmutableDictionary<string, double>.Empty;
|
||||
return new PolicyVerdict(
|
||||
findingId,
|
||||
PolicyVerdictStatus.Pass,
|
||||
RuleName: null,
|
||||
RuleAction: null,
|
||||
Notes: null,
|
||||
Score: 0,
|
||||
ConfigVersion: scoringConfig.Version,
|
||||
Inputs: inputs,
|
||||
QuietedBy: null,
|
||||
Quiet: false,
|
||||
UnknownConfidence: null,
|
||||
ConfidenceBand: null,
|
||||
UnknownAgeDays: null,
|
||||
SourceTrust: null,
|
||||
Reachability: null);
|
||||
}
|
||||
|
||||
public ImmutableDictionary<string, double> GetInputs()
|
||||
=> Inputs ?? ImmutableDictionary<string, double>.Empty;
|
||||
}
|
||||
|
||||
public sealed record PolicyVerdictDiff(
|
||||
PolicyVerdict Baseline,
|
||||
PolicyVerdict Projected)
|
||||
{
|
||||
public bool Changed
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Baseline.Status != Projected.Status)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.RuleName, Projected.RuleName, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Math.Abs(Baseline.Score - Projected.Score) > 0.0001)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.QuietedBy, Projected.QuietedBy, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var baselineConfidence = Baseline.UnknownConfidence ?? 0;
|
||||
var projectedConfidence = Projected.UnknownConfidence ?? 0;
|
||||
if (Math.Abs(baselineConfidence - projectedConfidence) > 0.0001)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.ConfidenceBand, Projected.ConfidenceBand, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.SourceTrust, Projected.SourceTrust, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.Reachability, Projected.Reachability, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.stella-ops.org/policy/policy-schema@1.json",
|
||||
"title": "StellaOps Policy Schema v1",
|
||||
"type": "object",
|
||||
"required": ["version", "rules"],
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": ["string", "number"],
|
||||
"enum": ["1", "1.0", 1, 1.0]
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": ["string", "number", "boolean"]
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"effects": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/exceptionEffect"
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"routingTemplates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/exceptionRoutingTemplate"
|
||||
},
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"rules": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/rule"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true,
|
||||
"$defs": {
|
||||
"identifier": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": ["Critical", "High", "Medium", "Low", "Informational", "None", "Unknown"]
|
||||
},
|
||||
"stringArray": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"exceptionEffect": {
|
||||
"type": "object",
|
||||
"required": ["id", "effect"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/identifier"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"effect": {
|
||||
"type": "string",
|
||||
"enum": ["suppress", "defer", "downgrade", "requireControl"]
|
||||
},
|
||||
"downgradeSeverity": {
|
||||
"$ref": "#/$defs/severity"
|
||||
},
|
||||
"requiredControlId": {
|
||||
"$ref": "#/$defs/identifier"
|
||||
},
|
||||
"routingTemplate": {
|
||||
"$ref": "#/$defs/identifier"
|
||||
},
|
||||
"maxDurationDays": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"effect": {
|
||||
"const": "downgrade"
|
||||
}
|
||||
},
|
||||
"required": ["effect"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["downgradeSeverity"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"effect": {
|
||||
"const": "requireControl"
|
||||
}
|
||||
},
|
||||
"required": ["effect"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["requiredControlId"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"exceptionRoutingTemplate": {
|
||||
"type": "object",
|
||||
"required": ["id", "authorityRouteId"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/identifier"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"authorityRouteId": {
|
||||
"$ref": "#/$defs/identifier"
|
||||
},
|
||||
"requireMfa": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"rule": {
|
||||
"type": "object",
|
||||
"required": ["name", "action"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/identifier"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"severity": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/severity"
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"sources": {
|
||||
"$ref": "#/$defs/stringArray"
|
||||
},
|
||||
"vendors": {
|
||||
"$ref": "#/$defs/stringArray"
|
||||
},
|
||||
"licenses": {
|
||||
"$ref": "#/$defs/stringArray"
|
||||
},
|
||||
"tags": {
|
||||
"$ref": "#/$defs/stringArray"
|
||||
},
|
||||
"environments": {
|
||||
"$ref": "#/$defs/stringArray"
|
||||
},
|
||||
"images": {
|
||||
"$ref": "#/$defs/stringArray"
|
||||
},
|
||||
"repositories": {
|
||||
"$ref": "#/$defs/stringArray"
|
||||
},
|
||||
"packages": {
|
||||
"$ref": "#/$defs/stringArray"
|
||||
},
|
||||
"purls": {
|
||||
"$ref": "#/$defs/stringArray"
|
||||
},
|
||||
"cves": {
|
||||
"$ref": "#/$defs/stringArray"
|
||||
},
|
||||
"paths": {
|
||||
"$ref": "#/$defs/stringArray"
|
||||
},
|
||||
"layerDigests": {
|
||||
"$ref": "#/$defs/stringArray"
|
||||
},
|
||||
"usedByEntrypoint": {
|
||||
"$ref": "#/$defs/stringArray"
|
||||
},
|
||||
"justification": {
|
||||
"type": "string"
|
||||
},
|
||||
"quiet": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"action": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["block", "fail", "deny", "ignore", "warn", "defer", "escalate", "requireVex"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"quiet": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"until": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"justification": {
|
||||
"type": "string"
|
||||
},
|
||||
"severity": {
|
||||
"$ref": "#/$defs/severity"
|
||||
},
|
||||
"vendors": {
|
||||
"$ref": "#/$defs/stringArray"
|
||||
},
|
||||
"justifications": {
|
||||
"$ref": "#/$defs/stringArray"
|
||||
},
|
||||
"epss": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"kev": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"expires": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"until": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": ["string", "number", "boolean"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"severityWeights": {
|
||||
"Critical": 90.0,
|
||||
"High": 75.0,
|
||||
"Medium": 50.0,
|
||||
"Low": 25.0,
|
||||
"Informational": 10.0,
|
||||
"None": 0.0,
|
||||
"Unknown": 60.0
|
||||
},
|
||||
"quietPenalty": 45.0,
|
||||
"warnPenalty": 15.0,
|
||||
"ignorePenalty": 35.0,
|
||||
"trustOverrides": {
|
||||
"vendor": 1.0,
|
||||
"distro": 0.85,
|
||||
"platform": 0.75,
|
||||
"community": 0.65
|
||||
},
|
||||
"reachabilityBuckets": {
|
||||
"entrypoint": 1.0,
|
||||
"direct": 0.85,
|
||||
"indirect": 0.6,
|
||||
"runtime": 0.45,
|
||||
"unreachable": 0.25,
|
||||
"unknown": 0.5
|
||||
},
|
||||
"unknownConfidence": {
|
||||
"initial": 0.8,
|
||||
"decayPerDay": 0.05,
|
||||
"floor": 0.2,
|
||||
"bands": [
|
||||
{
|
||||
"name": "high",
|
||||
"min": 0.65,
|
||||
"description": "Fresh unknowns with recent telemetry."
|
||||
},
|
||||
{
|
||||
"name": "medium",
|
||||
"min": 0.35,
|
||||
"description": "Unknowns aging toward action required."
|
||||
},
|
||||
{
|
||||
"name": "low",
|
||||
"min": 0.0,
|
||||
"description": "Stale unknowns that must be triaged."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.stella-ops.org/policy/policy-scoring-schema@1.json",
|
||||
"title": "StellaOps Policy Scoring Configuration v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"version",
|
||||
"severityWeights"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+\\.[0-9]+$"
|
||||
},
|
||||
"severityWeights": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Critical",
|
||||
"High",
|
||||
"Medium",
|
||||
"Low",
|
||||
"Informational",
|
||||
"None",
|
||||
"Unknown"
|
||||
],
|
||||
"properties": {
|
||||
"Critical": {
|
||||
"$ref": "#/$defs/weight"
|
||||
},
|
||||
"High": {
|
||||
"$ref": "#/$defs/weight"
|
||||
},
|
||||
"Medium": {
|
||||
"$ref": "#/$defs/weight"
|
||||
},
|
||||
"Low": {
|
||||
"$ref": "#/$defs/weight"
|
||||
},
|
||||
"Informational": {
|
||||
"$ref": "#/$defs/weight"
|
||||
},
|
||||
"None": {
|
||||
"$ref": "#/$defs/weight"
|
||||
},
|
||||
"Unknown": {
|
||||
"$ref": "#/$defs/weight"
|
||||
}
|
||||
}
|
||||
},
|
||||
"quietPenalty": {
|
||||
"$ref": "#/$defs/penalty"
|
||||
},
|
||||
"warnPenalty": {
|
||||
"$ref": "#/$defs/penalty"
|
||||
},
|
||||
"ignorePenalty": {
|
||||
"$ref": "#/$defs/penalty"
|
||||
},
|
||||
"trustOverrides": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"pattern": "^[a-z][a-z0-9_.-]*$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/trustWeight"
|
||||
}
|
||||
},
|
||||
"reachabilityBuckets": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"propertyNames": {
|
||||
"pattern": "^[a-z][a-z0-9_.-]*$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/reachabilityWeight"
|
||||
}
|
||||
},
|
||||
"unknownConfidence": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"initial",
|
||||
"decayPerDay",
|
||||
"floor",
|
||||
"bands"
|
||||
],
|
||||
"properties": {
|
||||
"initial": {
|
||||
"$ref": "#/$defs/confidence"
|
||||
},
|
||||
"decayPerDay": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"floor": {
|
||||
"$ref": "#/$defs/confidence"
|
||||
},
|
||||
"bands": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"min"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_.-]*$"
|
||||
},
|
||||
"min": {
|
||||
"$ref": "#/$defs/confidence"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"weight": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"penalty": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 200
|
||||
},
|
||||
"trustWeight": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 5
|
||||
},
|
||||
"reachabilityWeight": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1.5
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta5.25306.1" />
|
||||
<PackageReference Include="YamlDotNet" Version="13.7.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Schemas\policy-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\policy-scoring-default.json" />
|
||||
<EmbeddedResource Include="Schemas\policy-scoring-schema@1.json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public interface IPolicySnapshotRepository
|
||||
{
|
||||
Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<PolicySnapshot>> ListAsync(int limit, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(PolicySnapshot snapshot, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed class InMemoryPolicySnapshotRepository : IPolicySnapshotRepository
|
||||
{
|
||||
private readonly List<PolicySnapshot> _snapshots = new();
|
||||
private readonly SemaphoreSlim _mutex = new(1, 1);
|
||||
|
||||
public async Task AddAsync(PolicySnapshot snapshot, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (snapshot is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(snapshot));
|
||||
}
|
||||
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_snapshots.Add(snapshot);
|
||||
_snapshots.Sort(static (left, right) => left.RevisionNumber.CompareTo(right.RevisionNumber));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return _snapshots.Count == 0 ? null : _snapshots[^1];
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PolicySnapshot>> ListAsync(int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
IEnumerable<PolicySnapshot> query = _snapshots;
|
||||
if (limit > 0)
|
||||
{
|
||||
query = query.TakeLast(limit);
|
||||
}
|
||||
|
||||
return query.ToImmutableArray();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/Policy/__Libraries/StellaOps.Policy/TASKS.md
Normal file
38
src/Policy/__Libraries/StellaOps.Policy/TASKS.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Policy Engine Task Board — Epic 1: Aggregation-Only Contract
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| POLICY-AOC-19-001 | TODO | Policy Guild | WEB-AOC-19-003 | Add Roslyn/CI lint preventing ingestion projects from referencing Policy merge/severity helpers; block forbidden writes at compile time. | Analyzer fails builds when ingestion projects set banned keys or reference Policy-only namespaces; CI pipeline wired. |
|
||||
| POLICY-AOC-19-002 | TODO | Policy Guild, Platform Security | AUTH-AOC-19-001 | Enforce `effective_finding_*` write gate ensuring only Policy Engine identity can create/update materializations. | Guard rejects non-Policy identities with `ERR_AOC_006`; integration tests validate authorized writes; logs contain audit trail. |
|
||||
| POLICY-AOC-19-003 | TODO | Policy Guild | CONCELIER-CORE-AOC-19-004, EXCITITOR-CORE-AOC-19-004 | Update readers/processors to consume only `content.raw`, `identifiers`, and `linkset`. Remove dependencies on legacy normalized fields and refresh fixtures. | All policy pipelines pass tests using raw inputs; fixture diff shows no derived data persisted in ingestion; docs updated. |
|
||||
| POLICY-AOC-19-004 | TODO | Policy Guild, QA Guild | POLICY-AOC-19-003 | Add regression tests ensuring policy derived outputs remain deterministic when ingesting revised raw docs (supersedes) and when violations occur. | Determinism suite passes; new fixtures prove policy recomputation handles append-only raw data and surfaces guard violations. |
|
||||
|
||||
> Epic 2 service implementation tasks now live under `src/Policy/StellaOps.Policy.Engine/TASKS.md`. Keep library-specific work in this file.
|
||||
|
||||
## Policy Engine + Editor v1 (Epic 5)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| POLICY-SPL-23-001 | TODO | Policy Guild, Language Infrastructure Guild | POLICY-ENGINE-20-001 | Define SPL v1 YAML + JSON Schema, including advisory rules, VEX precedence, severity mapping, exceptions, and layering metadata. Publish schema resources and validation fixtures. | Schema files committed under `Schemas/`; validation tests cover representative policies; documentation draft ready. |
|
||||
| POLICY-SPL-23-002 | TODO | Policy Guild | POLICY-SPL-23-001 | Implement canonicalizer that normalizes policy packs (ordering, defaults), computes content hash, and prepares bundle metadata for AOC/signing. | Canonicalizer produces deterministic output (tests across permutations); hash matches spec; integration wired into compiler. |
|
||||
| POLICY-SPL-23-003 | TODO | Policy Guild | POLICY-SPL-23-001 | Build policy layering/override engine (global/org/project/env/exception) with field-level precedence matrices; add unit/property tests. | Layering engine honors override rules, rejects widening overrides; property tests ensure determinism. |
|
||||
| POLICY-SPL-23-004 | TODO | Policy Guild, Audit Guild | POLICY-SPL-23-002 | Design explanation tree model (rule hits, inputs, decisions) and persistence structures reused by runtime, UI, and CLI. | Explanation DTOs published; serialization deterministic; tests cover nested explanations. |
|
||||
| POLICY-SPL-23-005 | TODO | Policy Guild, DevEx Guild | POLICY-SPL-23-001 | Create migration tool to snapshot existing behavior into baseline SPL packs (`org.core.baseline`), including policy docs and sample bundles. | Tool emits baseline policy pack + tests verifying parity against legacy behavior; documentation updated. |
|
||||
|
||||
## Exceptions v1 (Epic 7)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| POLICY-EXC-25-001 | DONE (2025-10-27) | Policy Guild, Governance Guild | POLICY-SPL-23-001 | Extend SPL schema/spec to reference exception effects and routing templates; publish updated docs and validation fixtures. | Schema updated with exception references; validation tests cover effect types; docs draft ready. |
|
||||
|
||||
## Reachability v1 (Epic 8)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| POLICY-SPL-24-001 | TODO | Policy Guild, Signals Guild | SIGNALS-24-004 | Extend SPL schema to expose reachability/exploitability predicates and weighting functions; update documentation and fixtures. | Schema updated; validation fixtures for reachability rules; docs ready for review. |
|
||||
|
||||
## Risk Profiles (Epic 18)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| POLICY-RISK-66-004 | TODO | Policy Guild, Risk Profile Schema Guild | POLICY-RISK-66-001 | Extend Policy libraries to load/save RiskProfile documents, compute content hashes, and surface validation diagnostics. | Libraries serialize/deserialize profiles; hash deterministic; tests cover invalid input. |
|
||||
| POLICY-RISK-67-003 | TODO | Policy Guild, Risk Engine Guild | POLICY-RISK-66-004, RISK-ENGINE-69-001 | Provide policy-layer APIs to trigger risk simulations and return distributions/contribution breakdowns. | API returns simulation payload; golden tests match expected output. |
|
||||
| POLICY-RISK-68-002 | TODO | Policy Guild, Export Guild | POLICY-RISK-66-004 | Enable exporting/importing RiskProfiles with signatures via policy tooling (CLI + API). | Export/import round-trip tested; signatures verified; docs updated. |
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyCompilerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Compile_BaselinePolicy_Succeeds()
|
||||
{
|
||||
const string source = """
|
||||
policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
metadata {
|
||||
description = "Block critical, escalate high, enforce VEX justifications."
|
||||
tags = ["baseline","production"]
|
||||
}
|
||||
|
||||
profile severity {
|
||||
map vendor_weight {
|
||||
source "GHSA" => +0.5
|
||||
source "OSV" => +0.0
|
||||
}
|
||||
env exposure_adjustments {
|
||||
if env.exposure == "internet" then +0.5
|
||||
}
|
||||
}
|
||||
|
||||
rule block_critical priority 5 {
|
||||
when severity.normalized >= "Critical"
|
||||
then status := "blocked"
|
||||
because "Critical severity must be remediated before deploy."
|
||||
}
|
||||
|
||||
rule escalate_high_internet {
|
||||
when severity.normalized == "High"
|
||||
and env.exposure == "internet"
|
||||
then escalate to severity_band("Critical")
|
||||
because "High severity on internet-exposed asset escalates to critical."
|
||||
}
|
||||
|
||||
rule require_vex_justification {
|
||||
when vex.any(status in ["not_affected","fixed"])
|
||||
and vex.justification in ["component_not_present","vulnerable_code_not_present"]
|
||||
then status := vex.status
|
||||
annotate winning_statement := vex.latest().statementId
|
||||
because "Respect strong vendor VEX claims."
|
||||
}
|
||||
|
||||
rule alert_warn_eol_runtime priority 1 {
|
||||
when severity.normalized <= "Medium"
|
||||
and sbom.has_tag("runtime:eol")
|
||||
then warn message "Runtime marked as EOL; upgrade recommended."
|
||||
because "Deprecated runtime should be upgraded."
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var compiler = new PolicyCompiler();
|
||||
var result = compiler.Compile(source);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
throw new Xunit.Sdk.XunitException($"Compilation failed: {Describe(result.Diagnostics)}");
|
||||
}
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Checksum));
|
||||
Assert.NotEmpty(result.CanonicalRepresentation);
|
||||
Assert.All(result.Diagnostics, issue => Assert.NotEqual(PolicyIssueSeverity.Error, issue.Severity));
|
||||
|
||||
var document = Assert.IsType<PolicyIrDocument>(result.Document);
|
||||
Assert.Equal("Baseline Production Policy", document.Name);
|
||||
Assert.Equal("stella-dsl@1", document.Syntax);
|
||||
Assert.Equal(4, document.Rules.Length);
|
||||
Assert.Single(document.Profiles);
|
||||
var firstAction = Assert.IsType<PolicyIrAssignmentAction>(document.Rules[0].ThenActions[0]);
|
||||
Assert.Equal("status", firstAction.Target[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_MissingBecause_ReportsDiagnostic()
|
||||
{
|
||||
const string source = """
|
||||
policy "Incomplete" syntax "stella-dsl@1" {
|
||||
rule missing_because {
|
||||
when true
|
||||
then status := "suppressed"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var compiler = new PolicyCompiler();
|
||||
var result = compiler.Compile(source);
|
||||
|
||||
Assert.False(result.Success);
|
||||
PolicyIssue diagnostic = result.Diagnostics.First(issue => issue.Code == "POLICY-DSL-PARSE-006");
|
||||
Assert.Equal(PolicyIssueSeverity.Error, diagnostic.Severity);
|
||||
}
|
||||
|
||||
private static string Describe(ImmutableArray<PolicyIssue> issues) =>
|
||||
string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}"));
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyEvaluatorTests
|
||||
{
|
||||
private static readonly string BaselinePolicy = """
|
||||
policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
metadata {
|
||||
description = "Block critical, escalate high, enforce VEX justifications."
|
||||
tags = ["baseline","production"]
|
||||
}
|
||||
|
||||
profile severity {
|
||||
map vendor_weight {
|
||||
source "GHSA" => +0.5
|
||||
source "OSV" => +0.0
|
||||
}
|
||||
env exposure_adjustments {
|
||||
if env.exposure == "internet" then +0.5
|
||||
}
|
||||
}
|
||||
|
||||
rule block_critical priority 5 {
|
||||
when severity.normalized >= "Critical"
|
||||
then status := "blocked"
|
||||
because "Critical severity must be remediated before deploy."
|
||||
}
|
||||
|
||||
rule escalate_high_internet {
|
||||
when severity.normalized == "High"
|
||||
and env.exposure == "internet"
|
||||
then escalate to severity_band("Critical")
|
||||
because "High severity on internet-exposed asset escalates to critical."
|
||||
}
|
||||
|
||||
rule require_vex_justification {
|
||||
when vex.any(status in ["not_affected","fixed"])
|
||||
and vex.justification in ["component_not_present","vulnerable_code_not_present"]
|
||||
then status := vex.status
|
||||
annotate winning_statement := vex.latest().statementId
|
||||
because "Respect strong vendor VEX claims."
|
||||
}
|
||||
|
||||
rule alert_warn_eol_runtime priority 1 {
|
||||
when severity.normalized <= "Medium"
|
||||
and sbom.has_tag("runtime:eol")
|
||||
then warn message "Runtime marked as EOL; upgrade recommended."
|
||||
because "Deprecated runtime should be upgraded."
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
private readonly PolicyCompiler compiler = new();
|
||||
private readonly PolicyEvaluationService evaluationService = new();
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_BlockCriticalRuleMatches()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var context = CreateContext(severity: "Critical", exposure: "internal");
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("block_critical", result.RuleName);
|
||||
Assert.Equal("blocked", result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_EscalateAdjustsSeverity()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var context = CreateContext(severity: "High", exposure: "internet");
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("escalate_high_internet", result.RuleName);
|
||||
Assert.Equal("affected", result.Status);
|
||||
Assert.Equal("Critical", result.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_VexOverrideSetsStatusAndAnnotation()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var statements = ImmutableArray.Create(
|
||||
new PolicyEvaluationVexStatement("not_affected", "component_not_present", "stmt-001"));
|
||||
var context = CreateContext("Medium", "internal") with
|
||||
{
|
||||
Vex = new PolicyEvaluationVexEvidence(statements)
|
||||
};
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("require_vex_justification", result.RuleName);
|
||||
Assert.Equal("not_affected", result.Status);
|
||||
Assert.Equal("stmt-001", result.Annotations["winning_statement"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_WarnRuleEmitsWarning()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var tags = ImmutableHashSet.Create("runtime:eol");
|
||||
var context = CreateContext("Medium", "internal") with
|
||||
{
|
||||
Sbom = new PolicyEvaluationSbom(tags)
|
||||
};
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("alert_warn_eol_runtime", result.RuleName);
|
||||
Assert.Equal("warned", result.Status);
|
||||
Assert.Contains(result.Warnings, message => message.Contains("EOL", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ExceptionSuppressesCriticalFinding()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var effect = new PolicyExceptionEffect(
|
||||
Id: "suppress-critical",
|
||||
Name: "Critical Break Glass",
|
||||
Effect: PolicyExceptionEffectType.Suppress,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: "secops",
|
||||
MaxDurationDays: 7,
|
||||
Description: null);
|
||||
var scope = PolicyEvaluationExceptionScope.Create(ruleNames: new[] { "block_critical" });
|
||||
var instance = new PolicyEvaluationExceptionInstance(
|
||||
Id: "exc-001",
|
||||
EffectId: effect.Id,
|
||||
Scope: scope,
|
||||
CreatedAt: new DateTimeOffset(2025, 10, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
Metadata: ImmutableDictionary<string, string>.Empty);
|
||||
var exceptions = new PolicyEvaluationExceptions(
|
||||
ImmutableDictionary<string, PolicyExceptionEffect>.Empty.Add(effect.Id, effect),
|
||||
ImmutableArray.Create(instance));
|
||||
var context = CreateContext("Critical", "internal", exceptions);
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("block_critical", result.RuleName);
|
||||
Assert.Equal("suppressed", result.Status);
|
||||
Assert.NotNull(result.AppliedException);
|
||||
Assert.Equal("exc-001", result.AppliedException!.ExceptionId);
|
||||
Assert.Equal("suppress-critical", result.AppliedException!.EffectId);
|
||||
Assert.Equal("blocked", result.AppliedException!.OriginalStatus);
|
||||
Assert.Equal("suppressed", result.AppliedException!.AppliedStatus);
|
||||
Assert.Equal("suppressed", result.Annotations["exception.status"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ExceptionDowngradesSeverity()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var effect = new PolicyExceptionEffect(
|
||||
Id: "downgrade-internet",
|
||||
Name: "Downgrade High Internet",
|
||||
Effect: PolicyExceptionEffectType.Downgrade,
|
||||
DowngradeSeverity: PolicySeverity.Medium,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: null,
|
||||
MaxDurationDays: null,
|
||||
Description: null);
|
||||
var scope = PolicyEvaluationExceptionScope.Create(
|
||||
ruleNames: new[] { "escalate_high_internet" },
|
||||
severities: new[] { "High" },
|
||||
sources: new[] { "GHSA" });
|
||||
var instance = new PolicyEvaluationExceptionInstance(
|
||||
Id: "exc-200",
|
||||
EffectId: effect.Id,
|
||||
Scope: scope,
|
||||
CreatedAt: new DateTimeOffset(2025, 10, 2, 0, 0, 0, TimeSpan.Zero),
|
||||
Metadata: ImmutableDictionary<string, string>.Empty);
|
||||
var exceptions = new PolicyEvaluationExceptions(
|
||||
ImmutableDictionary<string, PolicyExceptionEffect>.Empty.Add(effect.Id, effect),
|
||||
ImmutableArray.Create(instance));
|
||||
var context = CreateContext("High", "internet", exceptions);
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("escalate_high_internet", result.RuleName);
|
||||
Assert.Equal("affected", result.Status);
|
||||
Assert.Equal("Medium", result.Severity);
|
||||
Assert.NotNull(result.AppliedException);
|
||||
Assert.Equal("Critical", result.AppliedException!.OriginalSeverity);
|
||||
Assert.Equal("Medium", result.AppliedException!.AppliedSeverity);
|
||||
Assert.Equal("Medium", result.Annotations["exception.severity"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_MoreSpecificExceptionWins()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var suppressGlobal = new PolicyExceptionEffect(
|
||||
Id: "suppress-critical-global",
|
||||
Name: "Global Critical Suppress",
|
||||
Effect: PolicyExceptionEffectType.Suppress,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: null,
|
||||
MaxDurationDays: null,
|
||||
Description: null);
|
||||
var suppressRule = new PolicyExceptionEffect(
|
||||
Id: "suppress-critical-rule",
|
||||
Name: "Rule Critical Suppress",
|
||||
Effect: PolicyExceptionEffectType.Suppress,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: null,
|
||||
MaxDurationDays: null,
|
||||
Description: null);
|
||||
|
||||
var globalInstance = new PolicyEvaluationExceptionInstance(
|
||||
Id: "exc-global",
|
||||
EffectId: suppressGlobal.Id,
|
||||
Scope: PolicyEvaluationExceptionScope.Create(severities: new[] { "Critical" }),
|
||||
CreatedAt: new DateTimeOffset(2025, 9, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
Metadata: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var ruleInstance = new PolicyEvaluationExceptionInstance(
|
||||
Id: "exc-rule",
|
||||
EffectId: suppressRule.Id,
|
||||
Scope: PolicyEvaluationExceptionScope.Create(
|
||||
ruleNames: new[] { "block_critical" },
|
||||
severities: new[] { "Critical" }),
|
||||
CreatedAt: new DateTimeOffset(2025, 10, 5, 0, 0, 0, TimeSpan.Zero),
|
||||
Metadata: ImmutableDictionary<string, string>.Empty.Add("requestedBy", "alice"));
|
||||
|
||||
var effects = ImmutableDictionary<string, PolicyExceptionEffect>.Empty
|
||||
.Add(suppressGlobal.Id, suppressGlobal)
|
||||
.Add(suppressRule.Id, suppressRule);
|
||||
|
||||
var exceptions = new PolicyEvaluationExceptions(
|
||||
effects,
|
||||
ImmutableArray.Create(globalInstance, ruleInstance));
|
||||
|
||||
var context = CreateContext("Critical", "internal", exceptions);
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("suppressed", result.Status);
|
||||
Assert.NotNull(result.AppliedException);
|
||||
Assert.Equal("exc-rule", result.AppliedException!.ExceptionId);
|
||||
Assert.Equal("Rule Critical Suppress", result.AppliedException!.Metadata["effectName"]);
|
||||
Assert.Equal("alice", result.AppliedException!.Metadata["requestedBy"]);
|
||||
Assert.Equal("alice", result.Annotations["exception.meta.requestedBy"]);
|
||||
}
|
||||
|
||||
private PolicyIrDocument CompileBaseline()
|
||||
{
|
||||
var compilation = compiler.Compile(BaselinePolicy);
|
||||
Assert.True(compilation.Success, Describe(compilation.Diagnostics));
|
||||
return Assert.IsType<PolicyIrDocument>(compilation.Document);
|
||||
}
|
||||
|
||||
private static PolicyEvaluationContext CreateContext(string severity, string exposure, PolicyEvaluationExceptions? exceptions = null)
|
||||
{
|
||||
return new PolicyEvaluationContext(
|
||||
new PolicyEvaluationSeverity(severity),
|
||||
new PolicyEvaluationEnvironment(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["exposure"] = exposure
|
||||
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase)),
|
||||
new PolicyEvaluationAdvisory("GHSA", ImmutableDictionary<string, string>.Empty),
|
||||
PolicyEvaluationVexEvidence.Empty,
|
||||
new PolicyEvaluationSbom(ImmutableHashSet<string>.Empty),
|
||||
exceptions ?? PolicyEvaluationExceptions.Empty);
|
||||
}
|
||||
|
||||
private static string Describe(ImmutableArray<PolicyIssue> issues) =>
|
||||
string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}"));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public class PolicyPackRepositoryTests
|
||||
{
|
||||
private readonly InMemoryPolicyPackRepository repository = new();
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateRevision_WithSingleApprover_ActivatesImmediately()
|
||||
{
|
||||
await repository.CreateAsync("pack-1", "Pack", CancellationToken.None);
|
||||
await repository.UpsertRevisionAsync("pack-1", 1, requiresTwoPersonApproval: false, PolicyRevisionStatus.Approved, CancellationToken.None);
|
||||
|
||||
var result = await repository.RecordActivationAsync("pack-1", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyActivationResultStatus.Activated, result.Status);
|
||||
Assert.NotNull(result.Revision);
|
||||
Assert.Equal(PolicyRevisionStatus.Active, result.Revision!.Status);
|
||||
Assert.Single(result.Revision.Approvals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateRevision_WithTwoPersonRequirement_ReturnsPendingUntilSecondApproval()
|
||||
{
|
||||
await repository.CreateAsync("pack-2", "Pack", CancellationToken.None);
|
||||
await repository.UpsertRevisionAsync("pack-2", 1, requiresTwoPersonApproval: true, PolicyRevisionStatus.Approved, CancellationToken.None);
|
||||
|
||||
var first = await repository.RecordActivationAsync("pack-2", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
|
||||
Assert.Equal(PolicyActivationResultStatus.PendingSecondApproval, first.Status);
|
||||
Assert.Equal(PolicyRevisionStatus.Approved, first.Revision!.Status);
|
||||
Assert.Single(first.Revision.Approvals);
|
||||
|
||||
var duplicate = await repository.RecordActivationAsync("pack-2", 1, "alice", DateTimeOffset.UtcNow, null, CancellationToken.None);
|
||||
Assert.Equal(PolicyActivationResultStatus.DuplicateApproval, duplicate.Status);
|
||||
|
||||
var second = await repository.RecordActivationAsync("pack-2", 1, "bob", DateTimeOffset.UtcNow, null, CancellationToken.None);
|
||||
Assert.Equal(PolicyActivationResultStatus.Activated, second.Status);
|
||||
Assert.Equal(PolicyRevisionStatus.Active, second.Revision!.Status);
|
||||
Assert.Equal(2, second.Revision.Approvals.Length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,548 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.IdentityModel.JsonWebTokens;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Polly.Utilities;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Gateway.Clients;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Tests;
|
||||
|
||||
public sealed class GatewayActivationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ActivateRevision_UsesServiceTokenFallback_And_RecordsMetrics()
|
||||
{
|
||||
await using var factory = new PolicyGatewayWebApplicationFactory();
|
||||
|
||||
var tokenClient = factory.Services.GetRequiredService<StubTokenClient>();
|
||||
tokenClient.Reset();
|
||||
|
||||
var recordingHandler = factory.Services.GetRequiredService<RecordingPolicyEngineHandler>();
|
||||
recordingHandler.Reset();
|
||||
|
||||
using var listener = new MeterListener();
|
||||
var activationMeasurements = new List<(long Value, string Outcome, string Source)>();
|
||||
var latencyMeasurements = new List<(double Value, string Outcome, string Source)>();
|
||||
|
||||
listener.InstrumentPublished += (instrument, meterListener) =>
|
||||
{
|
||||
if (instrument.Meter.Name != "StellaOps.Policy.Gateway")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
meterListener.EnableMeasurementEvents(instrument);
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_requests_total")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activationMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.SetMeasurementEventCallback<double>((instrument, value, tags, _) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_latency_ms")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
latencyMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/policy/packs/example/revisions/5:activate",
|
||||
new ActivatePolicyRevisionRequest("rollout window start"));
|
||||
|
||||
listener.Dispose();
|
||||
|
||||
var forwardedRequest = recordingHandler.LastRequest;
|
||||
var issuedTokens = tokenClient.RequestCount;
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new Xunit.Sdk.XunitException(
|
||||
$"Gateway response was {(int)response.StatusCode} {response.StatusCode}. " +
|
||||
$"Body: {responseBody}. IssuedTokens: {issuedTokens}. Forwarded: { (forwardedRequest is null ? "no" : "yes") }.");
|
||||
}
|
||||
|
||||
Assert.Equal(1, tokenClient.RequestCount);
|
||||
|
||||
Assert.NotNull(forwardedRequest);
|
||||
Assert.Equal(HttpMethod.Post, forwardedRequest!.Method);
|
||||
Assert.Equal("https://policy-engine.test/api/policy/packs/example/revisions/5:activate", forwardedRequest.RequestUri!.ToString());
|
||||
Assert.Equal("Bearer", forwardedRequest.Headers.Authorization?.Scheme);
|
||||
Assert.Equal("service-token", forwardedRequest.Headers.Authorization?.Parameter);
|
||||
Assert.False(forwardedRequest.Headers.TryGetValues("DPoP", out _), "Expected no DPoP header when DPoP is disabled.");
|
||||
|
||||
Assert.Contains(activationMeasurements, measurement =>
|
||||
measurement.Value == 1 &&
|
||||
measurement.Outcome == "activated" &&
|
||||
measurement.Source == "service");
|
||||
|
||||
Assert.Contains(latencyMeasurements, measurement =>
|
||||
measurement.Outcome == "activated" &&
|
||||
measurement.Source == "service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateRevision_RecordsMetrics_WhenUpstreamReturnsUnauthorized()
|
||||
{
|
||||
await using var factory = new PolicyGatewayWebApplicationFactory();
|
||||
|
||||
var tokenClient = factory.Services.GetRequiredService<StubTokenClient>();
|
||||
tokenClient.Reset();
|
||||
|
||||
var recordingHandler = factory.Services.GetRequiredService<RecordingPolicyEngineHandler>();
|
||||
recordingHandler.Reset();
|
||||
recordingHandler.SetResponseFactory(_ =>
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = "Unauthorized",
|
||||
Detail = "Caller token rejected.",
|
||||
Status = StatusCodes.Status401Unauthorized
|
||||
};
|
||||
return new HttpResponseMessage(HttpStatusCode.Unauthorized)
|
||||
{
|
||||
Content = JsonContent.Create(problem)
|
||||
};
|
||||
});
|
||||
|
||||
using var listener = new MeterListener();
|
||||
var activationMeasurements = new List<(long Value, string Outcome, string Source)>();
|
||||
var latencyMeasurements = new List<(double Value, string Outcome, string Source)>();
|
||||
|
||||
listener.InstrumentPublished += (instrument, meterListener) =>
|
||||
{
|
||||
if (instrument.Meter.Name != "StellaOps.Policy.Gateway")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
meterListener.EnableMeasurementEvents(instrument);
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_requests_total")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activationMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.SetMeasurementEventCallback<double>((instrument, value, tags, _) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_latency_ms")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
latencyMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/policy/packs/example/revisions/2:activate",
|
||||
new ActivatePolicyRevisionRequest("failure path"));
|
||||
|
||||
listener.Dispose();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
|
||||
Assert.Equal(1, tokenClient.RequestCount);
|
||||
|
||||
var forwardedRequest = recordingHandler.LastRequest;
|
||||
Assert.NotNull(forwardedRequest);
|
||||
Assert.Equal("service-token", forwardedRequest!.Headers.Authorization?.Parameter);
|
||||
|
||||
Assert.Contains(activationMeasurements, measurement =>
|
||||
measurement.Value == 1 &&
|
||||
measurement.Outcome == "unauthorized" &&
|
||||
measurement.Source == "service");
|
||||
|
||||
Assert.Contains(latencyMeasurements, measurement =>
|
||||
measurement.Outcome == "unauthorized" &&
|
||||
measurement.Source == "service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateRevision_RecordsMetrics_WhenUpstreamReturnsBadGateway()
|
||||
{
|
||||
await using var factory = new PolicyGatewayWebApplicationFactory();
|
||||
|
||||
var tokenClient = factory.Services.GetRequiredService<StubTokenClient>();
|
||||
tokenClient.Reset();
|
||||
|
||||
var recordingHandler = factory.Services.GetRequiredService<RecordingPolicyEngineHandler>();
|
||||
recordingHandler.Reset();
|
||||
recordingHandler.SetResponseFactory(_ =>
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = "Upstream error",
|
||||
Detail = "Policy Engine returned 502.",
|
||||
Status = StatusCodes.Status502BadGateway
|
||||
};
|
||||
return new HttpResponseMessage(HttpStatusCode.BadGateway)
|
||||
{
|
||||
Content = JsonContent.Create(problem)
|
||||
};
|
||||
});
|
||||
|
||||
using var listener = new MeterListener();
|
||||
var activationMeasurements = new List<(long Value, string Outcome, string Source)>();
|
||||
var latencyMeasurements = new List<(double Value, string Outcome, string Source)>();
|
||||
|
||||
listener.InstrumentPublished += (instrument, meterListener) =>
|
||||
{
|
||||
if (instrument.Meter.Name != "StellaOps.Policy.Gateway")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
meterListener.EnableMeasurementEvents(instrument);
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_requests_total")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activationMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.SetMeasurementEventCallback<double>((instrument, value, tags, _) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_latency_ms")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
latencyMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/policy/packs/example/revisions/3:activate",
|
||||
new ActivatePolicyRevisionRequest("upstream failure"));
|
||||
|
||||
listener.Dispose();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode);
|
||||
|
||||
Assert.Equal(1, tokenClient.RequestCount);
|
||||
|
||||
var forwardedRequest = recordingHandler.LastRequest;
|
||||
Assert.NotNull(forwardedRequest);
|
||||
Assert.Equal("service-token", forwardedRequest!.Headers.Authorization?.Parameter);
|
||||
|
||||
Assert.Contains(activationMeasurements, measurement =>
|
||||
measurement.Value == 1 &&
|
||||
measurement.Outcome == "error" &&
|
||||
measurement.Source == "service");
|
||||
|
||||
Assert.Contains(latencyMeasurements, measurement =>
|
||||
measurement.Outcome == "error" &&
|
||||
measurement.Source == "service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateRevision_RetriesOnTooManyRequests()
|
||||
{
|
||||
await using var factory = new PolicyGatewayWebApplicationFactory();
|
||||
|
||||
var recordedDelays = new List<TimeSpan>();
|
||||
var originalSleep = SystemClock.SleepAsync;
|
||||
SystemClock.SleepAsync = (delay, cancellationToken) =>
|
||||
{
|
||||
recordedDelays.Add(delay);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
var tokenClient = factory.Services.GetRequiredService<StubTokenClient>();
|
||||
tokenClient.Reset();
|
||||
|
||||
var recordingHandler = factory.Services.GetRequiredService<RecordingPolicyEngineHandler>();
|
||||
recordingHandler.Reset();
|
||||
recordingHandler.SetResponseSequence(new[]
|
||||
{
|
||||
CreateThrottleResponse(),
|
||||
CreateThrottleResponse(),
|
||||
RecordingPolicyEngineHandler.CreateSuccessResponse()
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/policy/packs/example/revisions/7:activate",
|
||||
new ActivatePolicyRevisionRequest("retry after throttle"));
|
||||
|
||||
Assert.True(response.IsSuccessStatusCode, "Gateway should succeed after retrying throttled upstream responses.");
|
||||
Assert.Equal(1, tokenClient.RequestCount);
|
||||
Assert.Equal(3, recordingHandler.RequestCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SystemClock.SleepAsync = originalSleep;
|
||||
}
|
||||
|
||||
Assert.Equal(new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4) }, recordedDelays);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateThrottleResponse()
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = "Too many requests",
|
||||
Detail = "Slow down.",
|
||||
Status = StatusCodes.Status429TooManyRequests
|
||||
};
|
||||
|
||||
var response = new HttpResponseMessage((HttpStatusCode)StatusCodes.Status429TooManyRequests)
|
||||
{
|
||||
Content = JsonContent.Create(problem)
|
||||
};
|
||||
response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromMilliseconds(10));
|
||||
return response;
|
||||
}
|
||||
|
||||
private static string GetTag(ReadOnlySpan<KeyValuePair<string, object?>> tags, string key)
|
||||
{
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (string.Equals(tag.Key, key, StringComparison.Ordinal))
|
||||
{
|
||||
return tag.Value?.ToString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private sealed class PolicyGatewayWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
|
||||
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
|
||||
{
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
["PolicyGateway:Telemetry:MinimumLogLevel"] = "Warning",
|
||||
["PolicyGateway:ResourceServer:Authority"] = "https://authority.test",
|
||||
["PolicyGateway:ResourceServer:RequireHttpsMetadata"] = "false",
|
||||
["PolicyGateway:ResourceServer:BypassNetworks:0"] = "127.0.0.1/32",
|
||||
["PolicyGateway:ResourceServer:BypassNetworks:1"] = "::1/128",
|
||||
["PolicyGateway:PolicyEngine:BaseAddress"] = "https://policy-engine.test/",
|
||||
["PolicyGateway:PolicyEngine:ClientCredentials:Enabled"] = "true",
|
||||
["PolicyGateway:PolicyEngine:ClientCredentials:ClientId"] = "policy-gateway",
|
||||
["PolicyGateway:PolicyEngine:ClientCredentials:ClientSecret"] = "secret",
|
||||
["PolicyGateway:PolicyEngine:ClientCredentials:Scopes:0"] = "policy:activate",
|
||||
["PolicyGateway:PolicyEngine:Dpop:Enabled"] = "false"
|
||||
};
|
||||
|
||||
configurationBuilder.AddInMemoryCollection(settings);
|
||||
});
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IStellaOpsTokenClient>();
|
||||
services.AddSingleton<StubTokenClient>();
|
||||
services.AddSingleton<IStellaOpsTokenClient>(sp => sp.GetRequiredService<StubTokenClient>());
|
||||
|
||||
services.RemoveAll<PolicyEngineClient>();
|
||||
services.RemoveAll<IPolicyEngineClient>();
|
||||
services.AddSingleton<RecordingPolicyEngineHandler>();
|
||||
services.AddHttpClient<IPolicyEngineClient, PolicyEngineClient>()
|
||||
.ConfigureHttpClient(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://policy-engine.test/");
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(sp => sp.GetRequiredService<RecordingPolicyEngineHandler>());
|
||||
|
||||
services.AddSingleton<IStartupFilter>(new RemoteIpStartupFilter());
|
||||
|
||||
services.PostConfigure<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.Configuration = new OpenIdConnectConfiguration
|
||||
{
|
||||
Issuer = "https://authority.test",
|
||||
TokenEndpoint = "https://authority.test/token"
|
||||
};
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateIssuerSigningKey = false,
|
||||
SignatureValidator = (token, parameters) => new JsonWebToken(token)
|
||||
};
|
||||
options.BackchannelHttpHandler = new NoOpBackchannelHandler();
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RemoteIpStartupFilter : IStartupFilter
|
||||
{
|
||||
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
|
||||
{
|
||||
return app =>
|
||||
{
|
||||
app.Use(async (context, innerNext) =>
|
||||
{
|
||||
context.Connection.RemoteIpAddress ??= IPAddress.Loopback;
|
||||
await innerNext().ConfigureAwait(false);
|
||||
});
|
||||
|
||||
next(app);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingPolicyEngineHandler : HttpMessageHandler
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
public int RequestCount { get; private set; }
|
||||
private Func<HttpRequestMessage, HttpResponseMessage>? responseFactory;
|
||||
private Queue<HttpResponseMessage>? responseQueue;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
RequestCount++;
|
||||
|
||||
if (responseQueue is { Count: > 0 })
|
||||
{
|
||||
return Task.FromResult(responseQueue.Dequeue());
|
||||
}
|
||||
|
||||
var response = responseFactory is not null
|
||||
? responseFactory(request)
|
||||
: CreateSuccessResponse();
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
LastRequest = null;
|
||||
RequestCount = 0;
|
||||
responseFactory = null;
|
||||
responseQueue?.Clear();
|
||||
responseQueue = null;
|
||||
}
|
||||
|
||||
public void SetResponseFactory(Func<HttpRequestMessage, HttpResponseMessage>? factory)
|
||||
{
|
||||
responseFactory = factory;
|
||||
}
|
||||
|
||||
public void SetResponseSequence(IEnumerable<HttpResponseMessage> responses)
|
||||
{
|
||||
responseQueue = new Queue<HttpResponseMessage>(responses ?? Array.Empty<HttpResponseMessage>());
|
||||
}
|
||||
|
||||
public static HttpResponseMessage CreateSuccessResponse()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var payload = new PolicyRevisionActivationDto(
|
||||
"activated",
|
||||
new PolicyRevisionDto(
|
||||
5,
|
||||
"activated",
|
||||
false,
|
||||
now,
|
||||
now,
|
||||
Array.Empty<PolicyActivationApprovalDto>()));
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(payload, options: SerializerOptions)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoOpBackchannelHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
private sealed class StubTokenClient : IStellaOpsTokenClient
|
||||
{
|
||||
public int RequestCount { get; private set; }
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
RequestCount = 0;
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequestCount++;
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(5);
|
||||
return Task.FromResult(new StellaOpsTokenResult("service-token", "Bearer", expiresAt, Array.Empty<string>()));
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
|
||||
|
||||
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Policy.Gateway.Clients;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Tests;
|
||||
|
||||
public class PolicyEngineClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ActivateRevision_UsesServiceTokenWhenForwardingContextMissing()
|
||||
{
|
||||
var options = CreateGatewayOptions();
|
||||
options.PolicyEngine.ClientCredentials.Enabled = true;
|
||||
options.PolicyEngine.ClientCredentials.ClientId = "policy-gateway";
|
||||
options.PolicyEngine.ClientCredentials.ClientSecret = "secret";
|
||||
options.PolicyEngine.ClientCredentials.Scopes.Clear();
|
||||
options.PolicyEngine.ClientCredentials.Scopes.Add("policy:activate");
|
||||
options.PolicyEngine.BaseAddress = "https://policy-engine.test/";
|
||||
|
||||
var optionsMonitor = new TestOptionsMonitor(options);
|
||||
var tokenClient = new StubTokenClient();
|
||||
var dpopGenerator = new PolicyGatewayDpopProofGenerator(new StubHostEnvironment(), optionsMonitor, TimeProvider.System, NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
|
||||
var tokenProvider = new PolicyEngineTokenProvider(tokenClient, optionsMonitor, dpopGenerator, TimeProvider.System, NullLogger<PolicyEngineTokenProvider>.Instance);
|
||||
|
||||
using var recordingHandler = new RecordingHandler();
|
||||
using var httpClient = new HttpClient(recordingHandler)
|
||||
{
|
||||
BaseAddress = new Uri(options.PolicyEngine.BaseAddress)
|
||||
};
|
||||
|
||||
var client = new PolicyEngineClient(httpClient, Microsoft.Extensions.Options.Options.Create(options), tokenProvider, NullLogger<PolicyEngineClient>.Instance);
|
||||
|
||||
var request = new ActivatePolicyRevisionRequest("comment");
|
||||
var result = await client.ActivatePolicyRevisionAsync(null, "pack-123", 7, request, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(recordingHandler.LastRequest);
|
||||
var authorization = recordingHandler.LastRequest!.Headers.Authorization;
|
||||
Assert.NotNull(authorization);
|
||||
Assert.Equal("Bearer", authorization!.Scheme);
|
||||
Assert.Equal("service-token", authorization.Parameter);
|
||||
Assert.Equal(1, tokenClient.RequestCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Metrics_RecordActivation_EmitsExpectedTags()
|
||||
{
|
||||
using var metrics = new PolicyGatewayMetrics();
|
||||
using var listener = new MeterListener();
|
||||
var measurements = new List<(long Value, string Outcome, string Source)>();
|
||||
var latencies = new List<(double Value, string Outcome, string Source)>();
|
||||
|
||||
listener.InstrumentPublished += (instrument, meterListener) =>
|
||||
{
|
||||
if (!string.Equals(instrument.Meter.Name, "StellaOps.Policy.Gateway", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
meterListener.EnableMeasurementEvents(instrument);
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_requests_total")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
measurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.SetMeasurementEventCallback<double>((instrument, value, tags, state) =>
|
||||
{
|
||||
if (instrument.Name != "policy_gateway_activation_latency_ms")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
latencies.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
|
||||
metrics.RecordActivation("activated", "service", 42.5);
|
||||
|
||||
listener.Dispose();
|
||||
|
||||
Assert.Contains(measurements, entry => entry.Value == 1 && entry.Outcome == "activated" && entry.Source == "service");
|
||||
Assert.Contains(latencies, entry => entry.Outcome == "activated" && entry.Source == "service" && entry.Value == 42.5);
|
||||
}
|
||||
|
||||
private static string GetTag(ReadOnlySpan<KeyValuePair<string, object?>> tags, string key)
|
||||
{
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (string.Equals(tag.Key, key, StringComparison.Ordinal))
|
||||
{
|
||||
return tag.Value?.ToString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static PolicyGatewayOptions CreateGatewayOptions()
|
||||
{
|
||||
return new PolicyGatewayOptions
|
||||
{
|
||||
PolicyEngine =
|
||||
{
|
||||
BaseAddress = "https://policy-engine.test/"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor : IOptionsMonitor<PolicyGatewayOptions>
|
||||
{
|
||||
public TestOptionsMonitor(PolicyGatewayOptions current)
|
||||
{
|
||||
CurrentValue = current;
|
||||
}
|
||||
|
||||
public PolicyGatewayOptions CurrentValue { get; }
|
||||
|
||||
public PolicyGatewayOptions Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable OnChange(Action<PolicyGatewayOptions, string?> listener) => EmptyDisposable.Instance;
|
||||
|
||||
private sealed class EmptyDisposable : IDisposable
|
||||
{
|
||||
public static readonly EmptyDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubTokenClient : IStellaOpsTokenClient
|
||||
{
|
||||
public int RequestCount { get; private set; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? LastAdditionalParameters { get; private set; }
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequestCount++;
|
||||
LastAdditionalParameters = additionalParameters;
|
||||
return Task.FromResult(new StellaOpsTokenResult("service-token", "Bearer", DateTimeOffset.UtcNow.AddMinutes(5), Array.Empty<string>()));
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
|
||||
|
||||
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
|
||||
var payload = JsonSerializer.Serialize(new PolicyRevisionActivationDto("activated", new PolicyRevisionDto(7, "Activated", false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, Array.Empty<PolicyActivationApprovalDto>())));
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubHostEnvironment : IHostEnvironment
|
||||
{
|
||||
public string EnvironmentName { get; set; } = "Development";
|
||||
public string ApplicationName { get; set; } = "PolicyGatewayTests";
|
||||
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
|
||||
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Globalization;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Tests;
|
||||
|
||||
public sealed class PolicyGatewayDpopProofGeneratorTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateProof_Throws_WhenDpopDisabled()
|
||||
{
|
||||
var options = CreateGatewayOptions();
|
||||
options.PolicyEngine.Dpop.Enabled = false;
|
||||
|
||||
using var generator = new PolicyGatewayDpopProofGenerator(
|
||||
new StubHostEnvironment(AppContext.BaseDirectory),
|
||||
new TestOptionsMonitor(options),
|
||||
TimeProvider.System,
|
||||
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
generator.CreateProof(HttpMethod.Get, new Uri("https://policy-engine.example/api"), null));
|
||||
|
||||
Assert.Equal("DPoP proof requested while DPoP is disabled.", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProof_Throws_WhenKeyFileMissing()
|
||||
{
|
||||
var tempRoot = Directory.CreateTempSubdirectory();
|
||||
try
|
||||
{
|
||||
var options = CreateGatewayOptions();
|
||||
options.PolicyEngine.Dpop.Enabled = true;
|
||||
options.PolicyEngine.Dpop.KeyPath = "missing-key.pem";
|
||||
|
||||
using var generator = new PolicyGatewayDpopProofGenerator(
|
||||
new StubHostEnvironment(tempRoot.FullName),
|
||||
new TestOptionsMonitor(options),
|
||||
TimeProvider.System,
|
||||
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
|
||||
|
||||
var exception = Assert.Throws<FileNotFoundException>(() =>
|
||||
generator.CreateProof(HttpMethod.Post, new Uri("https://policy-engine.example/token"), null));
|
||||
|
||||
Assert.Contains("missing-key.pem", exception.FileName, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tempRoot.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProof_UsesConfiguredAlgorithmAndEmbedsTokenHash()
|
||||
{
|
||||
var tempRoot = Directory.CreateTempSubdirectory();
|
||||
try
|
||||
{
|
||||
var keyPath = CreateEcKey(tempRoot, ECCurve.NamedCurves.nistP384);
|
||||
var options = CreateGatewayOptions();
|
||||
options.PolicyEngine.Dpop.Enabled = true;
|
||||
options.PolicyEngine.Dpop.KeyPath = keyPath;
|
||||
options.PolicyEngine.Dpop.Algorithm = "ES384";
|
||||
|
||||
using var generator = new PolicyGatewayDpopProofGenerator(
|
||||
new StubHostEnvironment(tempRoot.FullName),
|
||||
new TestOptionsMonitor(options),
|
||||
TimeProvider.System,
|
||||
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
|
||||
|
||||
const string accessToken = "sample-access-token";
|
||||
var proof = generator.CreateProof(HttpMethod.Delete, new Uri("https://policy-engine.example/api/resource"), accessToken);
|
||||
|
||||
var token = new JwtSecurityTokenHandler().ReadJwtToken(proof);
|
||||
|
||||
Assert.Equal("dpop+jwt", token.Header.Typ);
|
||||
Assert.Equal("ES384", token.Header.Alg);
|
||||
Assert.Equal("DELETE", token.Payload.TryGetValue("htm", out var method) ? method?.ToString() : null);
|
||||
Assert.Equal("https://policy-engine.example/api/resource", token.Payload.TryGetValue("htu", out var uri) ? uri?.ToString() : null);
|
||||
|
||||
Assert.True(token.Payload.TryGetValue("iat", out var issuedAt));
|
||||
Assert.True(long.TryParse(Convert.ToString(issuedAt, CultureInfo.InvariantCulture), out var epoch));
|
||||
Assert.True(epoch > 0);
|
||||
|
||||
Assert.True(token.Payload.TryGetValue("jti", out var jti));
|
||||
Assert.False(string.IsNullOrWhiteSpace(Convert.ToString(jti, CultureInfo.InvariantCulture)));
|
||||
|
||||
Assert.True(token.Payload.TryGetValue("ath", out var ath));
|
||||
var expectedHash = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(accessToken)));
|
||||
Assert.Equal(expectedHash, ath?.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
tempRoot.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static PolicyGatewayOptions CreateGatewayOptions()
|
||||
{
|
||||
return new PolicyGatewayOptions
|
||||
{
|
||||
PolicyEngine =
|
||||
{
|
||||
BaseAddress = "https://policy-engine.example"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateEcKey(DirectoryInfo directory, ECCurve curve)
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(curve);
|
||||
var privateKey = ecdsa.ExportPkcs8PrivateKey();
|
||||
var pem = PemEncoding.Write("PRIVATE KEY", privateKey);
|
||||
var path = Path.Combine(directory.FullName, "policy-gateway-dpop.pem");
|
||||
File.WriteAllText(path, pem);
|
||||
return path;
|
||||
}
|
||||
|
||||
private sealed class StubHostEnvironment : IHostEnvironment
|
||||
{
|
||||
public StubHostEnvironment(string contentRootPath)
|
||||
{
|
||||
ContentRootPath = contentRootPath;
|
||||
}
|
||||
|
||||
public string ApplicationName { get; set; } = "PolicyGatewayTests";
|
||||
|
||||
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
|
||||
|
||||
public string ContentRootPath { get; set; }
|
||||
|
||||
public string EnvironmentName { get; set; } = Environments.Development;
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor : IOptionsMonitor<PolicyGatewayOptions>
|
||||
{
|
||||
public TestOptionsMonitor(PolicyGatewayOptions current)
|
||||
{
|
||||
CurrentValue = current;
|
||||
}
|
||||
|
||||
public PolicyGatewayOptions CurrentValue { get; }
|
||||
|
||||
public PolicyGatewayOptions Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable OnChange(Action<PolicyGatewayOptions, string?> listener) => EmptyDisposable.Instance;
|
||||
|
||||
private sealed class EmptyDisposable : IDisposable
|
||||
{
|
||||
public static readonly EmptyDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Policy.Gateway/StellaOps.Policy.Gateway.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
157
src/Policy/__Tests/StellaOps.Policy.Tests/PolicyBinderTests.cs
Normal file
157
src/Policy/__Tests/StellaOps.Policy.Tests/PolicyBinderTests.cs
Normal file
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyBinderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Bind_ValidYaml_ReturnsSuccess()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Block Critical
|
||||
severity: [Critical]
|
||||
sources: [NVD]
|
||||
action: block
|
||||
""";
|
||||
|
||||
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("1.0", result.Document.Version);
|
||||
Assert.Single(result.Document.Rules);
|
||||
Assert.Empty(result.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_ExceptionsConfigured_ParsesDefinitions()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
exceptions:
|
||||
effects:
|
||||
- id: suppress-temp
|
||||
name: Temporary Suppress
|
||||
effect: suppress
|
||||
routingTemplate: secops
|
||||
maxDurationDays: 30
|
||||
- id: downgrade-ops
|
||||
name: Downgrade To Low
|
||||
effect: downgrade
|
||||
downgradeSeverity: Low
|
||||
routingTemplates:
|
||||
- id: secops
|
||||
authorityRouteId: route-secops
|
||||
requireMfa: true
|
||||
rules:
|
||||
- name: Allow
|
||||
action: ignore
|
||||
""";
|
||||
|
||||
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
|
||||
|
||||
Assert.True(result.Success);
|
||||
var effects = result.Document.Exceptions.Effects;
|
||||
Assert.Equal(2, effects.Length);
|
||||
|
||||
var suppress = effects.Single(effect => effect.Id == "suppress-temp");
|
||||
Assert.Equal(PolicyExceptionEffectType.Suppress, suppress.Effect);
|
||||
Assert.Equal("Temporary Suppress", suppress.Name);
|
||||
Assert.Equal("secops", suppress.RoutingTemplate);
|
||||
Assert.Equal(30, suppress.MaxDurationDays);
|
||||
|
||||
var downgrade = effects.Single(effect => effect.Id == "downgrade-ops");
|
||||
Assert.Equal(PolicyExceptionEffectType.Downgrade, downgrade.Effect);
|
||||
Assert.Equal("Downgrade To Low", downgrade.Name);
|
||||
Assert.Equal(PolicySeverity.Low, downgrade.DowngradeSeverity);
|
||||
|
||||
var routing = result.Document.Exceptions.RoutingTemplates;
|
||||
Assert.Single(routing);
|
||||
Assert.Equal("secops", routing[0].Id);
|
||||
Assert.Equal("route-secops", routing[0].AuthorityRouteId);
|
||||
Assert.True(routing[0].RequireMfa);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_ExceptionDowngradeMissingSeverity_ReturnsError()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
exceptions:
|
||||
effects:
|
||||
- id: downgrade-invalid
|
||||
effect: downgrade
|
||||
routingTemplates: []
|
||||
rules:
|
||||
- name: Allow
|
||||
action: ignore
|
||||
""";
|
||||
|
||||
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Issues, issue => issue.Code == "policy.exceptions.effect.downgrade.missingSeverity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_InvalidSeverity_ReturnsError()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Invalid Severity
|
||||
severity: [Nope]
|
||||
action: block
|
||||
""";
|
||||
|
||||
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Issues, issue => issue.Code == "policy.severity.invalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_StrictMode_FailsOnWarnings()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Quiet Warning
|
||||
sources: ["", "NVD"]
|
||||
action: ignore
|
||||
""";
|
||||
|
||||
var path = Path.Combine(Path.GetTempPath(), $"policy-{Guid.NewGuid():N}.yaml");
|
||||
await File.WriteAllTextAsync(path, yaml);
|
||||
|
||||
try
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
var cli = new PolicyValidationCli(output, error);
|
||||
var options = new PolicyValidationCliOptions
|
||||
{
|
||||
Inputs = new[] { path },
|
||||
Strict = true,
|
||||
};
|
||||
|
||||
var exitCode = await cli.RunAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, exitCode);
|
||||
Assert.Contains("WARNING", output.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Collections.Immutable;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyEvaluationTests
|
||||
{
|
||||
[Fact]
|
||||
public void EvaluateFinding_AppliesTrustAndReachabilityWeights()
|
||||
{
|
||||
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
|
||||
var rule = PolicyRule.Create(
|
||||
"BlockMedium",
|
||||
action,
|
||||
ImmutableArray.Create(PolicySeverity.Medium),
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
PolicyRuleMatchCriteria.Empty,
|
||||
expires: null,
|
||||
justification: null);
|
||||
var document = new PolicyDocument(
|
||||
PolicySchema.CurrentVersion,
|
||||
ImmutableArray.Create(rule),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
PolicyExceptionConfiguration.Empty);
|
||||
|
||||
var config = PolicyScoringConfig.Default;
|
||||
var finding = PolicyFinding.Create(
|
||||
"finding-medium",
|
||||
PolicySeverity.Medium,
|
||||
source: "community",
|
||||
tags: ImmutableArray.Create("reachability:indirect"));
|
||||
|
||||
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding);
|
||||
|
||||
Assert.Equal(PolicyVerdictStatus.Blocked, verdict.Status);
|
||||
Assert.Equal(19.5, verdict.Score, 3);
|
||||
|
||||
var inputs = verdict.GetInputs();
|
||||
Assert.Equal(50, inputs["severityWeight"]);
|
||||
Assert.Equal(0.65, inputs["trustWeight"], 3);
|
||||
Assert.Equal(0.6, inputs["reachabilityWeight"], 3);
|
||||
Assert.Equal(19.5, inputs["baseScore"], 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateFinding_QuietWithRequireVexAppliesQuietPenalty()
|
||||
{
|
||||
var ignoreOptions = new PolicyIgnoreOptions(null, null);
|
||||
var requireVexOptions = new PolicyRequireVexOptions(
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty);
|
||||
var action = new PolicyAction(PolicyActionType.Ignore, ignoreOptions, null, requireVexOptions, true);
|
||||
var rule = PolicyRule.Create(
|
||||
"QuietIgnore",
|
||||
action,
|
||||
ImmutableArray.Create(PolicySeverity.Critical),
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
PolicyRuleMatchCriteria.Empty,
|
||||
expires: null,
|
||||
justification: null);
|
||||
|
||||
var document = new PolicyDocument(
|
||||
PolicySchema.CurrentVersion,
|
||||
ImmutableArray.Create(rule),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
PolicyExceptionConfiguration.Empty);
|
||||
|
||||
var config = PolicyScoringConfig.Default;
|
||||
var finding = PolicyFinding.Create(
|
||||
"finding-critical",
|
||||
PolicySeverity.Critical,
|
||||
tags: ImmutableArray.Create("reachability:entrypoint"));
|
||||
|
||||
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding);
|
||||
|
||||
Assert.Equal(PolicyVerdictStatus.Ignored, verdict.Status);
|
||||
Assert.True(verdict.Quiet);
|
||||
Assert.Equal("QuietIgnore", verdict.QuietedBy);
|
||||
Assert.Equal(10, verdict.Score, 3);
|
||||
|
||||
var inputs = verdict.GetInputs();
|
||||
Assert.Equal(90, inputs["baseScore"], 3);
|
||||
Assert.Equal(config.IgnorePenalty, inputs["ignorePenalty"]);
|
||||
Assert.Equal(config.QuietPenalty, inputs["quietPenalty"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateFinding_UnknownSeverityComputesConfidence()
|
||||
{
|
||||
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
|
||||
var rule = PolicyRule.Create(
|
||||
"BlockUnknown",
|
||||
action,
|
||||
ImmutableArray.Create(PolicySeverity.Unknown),
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
PolicyRuleMatchCriteria.Empty,
|
||||
expires: null,
|
||||
justification: null);
|
||||
|
||||
var document = new PolicyDocument(
|
||||
PolicySchema.CurrentVersion,
|
||||
ImmutableArray.Create(rule),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
PolicyExceptionConfiguration.Empty);
|
||||
|
||||
var config = PolicyScoringConfig.Default;
|
||||
var finding = PolicyFinding.Create(
|
||||
"finding-unknown",
|
||||
PolicySeverity.Unknown,
|
||||
tags: ImmutableArray.Create("reachability:unknown", "unknown-age-days:5"));
|
||||
|
||||
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding);
|
||||
|
||||
Assert.Equal(PolicyVerdictStatus.Blocked, verdict.Status);
|
||||
Assert.Equal(30, verdict.Score, 3); // 60 * 1 * 0.5
|
||||
Assert.Equal(0.55, verdict.UnknownConfidence ?? 0, 3);
|
||||
Assert.Equal("medium", verdict.ConfidenceBand);
|
||||
Assert.Equal(5, verdict.UnknownAgeDays ?? 0, 3);
|
||||
|
||||
var inputs = verdict.GetInputs();
|
||||
Assert.Equal(0.55, inputs["unknownConfidence"], 3);
|
||||
Assert.Equal(5, inputs["unknownAgeDays"], 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyPreviewServiceTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public PolicyPreviewServiceTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output ?? throw new ArgumentNullException(nameof(output));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_ComputesDiffs_ForBlockingRule()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Block Critical
|
||||
severity: [Critical]
|
||||
action: block
|
||||
""";
|
||||
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var timeProvider = new FakeTimeProvider();
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
|
||||
|
||||
await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, null), CancellationToken.None);
|
||||
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
|
||||
|
||||
var findings = ImmutableArray.Create(
|
||||
PolicyFinding.Create("finding-1", PolicySeverity.Critical, environment: "prod", source: "NVD"),
|
||||
PolicyFinding.Create("finding-2", PolicySeverity.Low));
|
||||
|
||||
var baseline = ImmutableArray.Create(
|
||||
new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass),
|
||||
new PolicyVerdict("finding-2", PolicyVerdictStatus.Pass));
|
||||
|
||||
var response = await service.PreviewAsync(new PolicyPreviewRequest(
|
||||
"sha256:abc",
|
||||
findings,
|
||||
baseline),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(response.Success);
|
||||
Assert.Equal(1, response.ChangedCount);
|
||||
var diff1 = Assert.Single(response.Diffs.Where(diff => diff.Projected.FindingId == "finding-1"));
|
||||
Assert.Equal(PolicyVerdictStatus.Pass, diff1.Baseline.Status);
|
||||
Assert.Equal(PolicyVerdictStatus.Blocked, diff1.Projected.Status);
|
||||
Assert.Equal("Block Critical", diff1.Projected.RuleName);
|
||||
Assert.True(diff1.Projected.Score > 0);
|
||||
Assert.Equal(PolicyScoringConfig.Default.Version, diff1.Projected.ConfigVersion);
|
||||
Assert.Equal(PolicyVerdictStatus.Pass, response.Diffs.First(diff => diff.Projected.FindingId == "finding-2").Projected.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_UsesProposedPolicy_WhenProvided()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Ignore Dev
|
||||
environments: [dev]
|
||||
action:
|
||||
type: ignore
|
||||
justification: dev waiver
|
||||
""";
|
||||
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
|
||||
|
||||
var findings = ImmutableArray.Create(
|
||||
PolicyFinding.Create("finding-1", PolicySeverity.Medium, environment: "dev"));
|
||||
|
||||
var baseline = ImmutableArray.Create(new PolicyVerdict("finding-1", PolicyVerdictStatus.Blocked));
|
||||
|
||||
var response = await service.PreviewAsync(new PolicyPreviewRequest(
|
||||
"sha256:def",
|
||||
findings,
|
||||
baseline,
|
||||
SnapshotOverride: null,
|
||||
ProposedPolicy: new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, "dev override")),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(response.Success);
|
||||
var diff = Assert.Single(response.Diffs);
|
||||
Assert.Equal(PolicyVerdictStatus.Blocked, diff.Baseline.Status);
|
||||
Assert.Equal(PolicyVerdictStatus.Ignored, diff.Projected.Status);
|
||||
Assert.Equal("Ignore Dev", diff.Projected.RuleName);
|
||||
Assert.True(diff.Projected.Score >= 0);
|
||||
Assert.Equal(1, response.ChangedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_ReturnsIssues_WhenPolicyInvalid()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
|
||||
|
||||
const string invalid = "version: 1.0";
|
||||
var request = new PolicyPreviewRequest(
|
||||
"sha256:ghi",
|
||||
ImmutableArray<PolicyFinding>.Empty,
|
||||
ImmutableArray<PolicyVerdict>.Empty,
|
||||
SnapshotOverride: null,
|
||||
ProposedPolicy: new PolicySnapshotContent(invalid, PolicyDocumentFormat.Yaml, null, null, null));
|
||||
|
||||
var response = await service.PreviewAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.False(response.Success);
|
||||
Assert.NotEmpty(response.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_QuietWithoutVexDowngradesToWarn()
|
||||
{
|
||||
const string yaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Quiet Without VEX
|
||||
severity: [Low]
|
||||
quiet: true
|
||||
action:
|
||||
type: ignore
|
||||
""";
|
||||
|
||||
var binding = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
|
||||
if (!binding.Success)
|
||||
{
|
||||
foreach (var issue in binding.Issues)
|
||||
{
|
||||
_output.WriteLine($"{issue.Severity} {issue.Code} {issue.Path} :: {issue.Message}");
|
||||
}
|
||||
|
||||
var parseMethod = typeof(PolicyBinder).GetMethod("ParseToNode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
var node = (System.Text.Json.Nodes.JsonNode?)parseMethod?.Invoke(null, new object[] { yaml, PolicyDocumentFormat.Yaml });
|
||||
_output.WriteLine(node?.ToJsonString() ?? "<null>");
|
||||
}
|
||||
Assert.True(binding.Success);
|
||||
Assert.Empty(binding.Issues);
|
||||
Assert.False(binding.Document.Rules[0].Metadata.ContainsKey("quiet"));
|
||||
Assert.True(binding.Document.Rules[0].Action.Quiet);
|
||||
|
||||
var store = new PolicySnapshotStore(new InMemoryPolicySnapshotRepository(), new InMemoryPolicyAuditRepository(), TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
|
||||
await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, "quiet test"), CancellationToken.None);
|
||||
var snapshot = await store.GetLatestAsync();
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.True(snapshot!.Document.Rules[0].Action.Quiet);
|
||||
Assert.Null(snapshot.Document.Rules[0].Action.RequireVex);
|
||||
Assert.Equal(PolicyActionType.Ignore, snapshot.Document.Rules[0].Action.Type);
|
||||
var manualVerdict = PolicyEvaluation.EvaluateFinding(snapshot.Document, snapshot.ScoringConfig, PolicyFinding.Create("finding-quiet", PolicySeverity.Low));
|
||||
Assert.Equal(PolicyVerdictStatus.Warned, manualVerdict.Status);
|
||||
|
||||
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
|
||||
|
||||
var findings = ImmutableArray.Create(PolicyFinding.Create("finding-quiet", PolicySeverity.Low));
|
||||
var baseline = ImmutableArray<PolicyVerdict>.Empty;
|
||||
|
||||
var response = await service.PreviewAsync(new PolicyPreviewRequest(
|
||||
"sha256:quiet",
|
||||
findings,
|
||||
baseline),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(response.Success);
|
||||
var verdict = Assert.Single(response.Diffs).Projected;
|
||||
Assert.Equal(PolicyVerdictStatus.Warned, verdict.Status);
|
||||
Assert.Contains("requireVex", verdict.Notes, System.StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(verdict.Score >= 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicyScoringConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void LoadDefaultReturnsConfig()
|
||||
{
|
||||
var config = PolicyScoringConfigBinder.LoadDefault();
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal("1.0", config.Version);
|
||||
Assert.NotEmpty(config.SeverityWeights);
|
||||
Assert.True(config.SeverityWeights.ContainsKey(PolicySeverity.Critical));
|
||||
Assert.True(config.QuietPenalty > 0);
|
||||
Assert.NotEmpty(config.ReachabilityBuckets);
|
||||
Assert.Contains("entrypoint", config.ReachabilityBuckets.Keys);
|
||||
Assert.False(config.UnknownConfidence.Bands.IsDefaultOrEmpty);
|
||||
Assert.Equal("high", config.UnknownConfidence.Bands[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindRejectsEmptyContent()
|
||||
{
|
||||
var result = PolicyScoringConfigBinder.Bind(string.Empty, PolicyDocumentFormat.Json);
|
||||
Assert.False(result.Success);
|
||||
Assert.NotEmpty(result.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindRejectsInvalidSchema()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"version": "1.0",
|
||||
"severityWeights": {
|
||||
"Critical": 90.0
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = PolicyScoringConfigBinder.Bind(json, PolicyDocumentFormat.Json);
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Issues, issue => issue.Code.StartsWith("scoring.schema", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Null(result.Config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultResourceDigestMatchesGolden()
|
||||
{
|
||||
var assembly = typeof(PolicyScoringConfig).Assembly;
|
||||
using var stream = assembly.GetManifestResourceStream("StellaOps.Policy.Schemas.policy-scoring-default.json")
|
||||
?? throw new InvalidOperationException("Unable to locate embedded scoring default resource.");
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
|
||||
var binding = PolicyScoringConfigBinder.Bind(json, PolicyDocumentFormat.Json);
|
||||
Assert.True(binding.Success);
|
||||
Assert.NotNull(binding.Config);
|
||||
|
||||
var digest = PolicyScoringConfigDigest.Compute(binding.Config!);
|
||||
Assert.Equal("5ef2e43a112cb00753beb7811dd2e1720f2385e2289d0fb6abcf7bbbb8cda2d2", digest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests;
|
||||
|
||||
public sealed class PolicySnapshotStoreTests
|
||||
{
|
||||
private const string BasePolicyYaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Block Critical
|
||||
severity: [Critical]
|
||||
action: block
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_CreatesNewSnapshotAndAuditEntry()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero));
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
|
||||
|
||||
var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null);
|
||||
|
||||
var result = await store.SaveAsync(content, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.Created);
|
||||
Assert.NotNull(result.Snapshot);
|
||||
Assert.Equal("rev-1", result.Snapshot!.RevisionId);
|
||||
Assert.Equal(result.Digest, result.Snapshot.Digest);
|
||||
Assert.Equal(timeProvider.GetUtcNow(), result.Snapshot.CreatedAt);
|
||||
Assert.Equal(PolicyScoringConfig.Default.Version, result.Snapshot.ScoringConfig.Version);
|
||||
|
||||
var latest = await store.GetLatestAsync();
|
||||
Assert.Equal(result.Snapshot, latest);
|
||||
|
||||
var audits = await auditRepo.ListAsync(10);
|
||||
Assert.Single(audits);
|
||||
Assert.Equal(result.Digest, audits[0].Digest);
|
||||
Assert.Equal("snapshot.created", audits[0].Action);
|
||||
Assert.Equal("rev-1", audits[0].RevisionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_DoesNotCreateNewRevisionWhenDigestUnchanged()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero));
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
|
||||
|
||||
var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null);
|
||||
var first = await store.SaveAsync(content, CancellationToken.None);
|
||||
Assert.True(first.Created);
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromHours(1));
|
||||
var second = await store.SaveAsync(content, CancellationToken.None);
|
||||
|
||||
Assert.True(second.Success);
|
||||
Assert.False(second.Created);
|
||||
Assert.Equal(first.Digest, second.Digest);
|
||||
Assert.Equal("rev-1", second.Snapshot!.RevisionId);
|
||||
Assert.Equal(PolicyScoringConfig.Default.Version, second.Snapshot.ScoringConfig.Version);
|
||||
|
||||
var audits = await auditRepo.ListAsync(10);
|
||||
Assert.Single(audits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_ReturnsFailureWhenValidationFails()
|
||||
{
|
||||
var snapshotRepo = new InMemoryPolicySnapshotRepository();
|
||||
var auditRepo = new InMemoryPolicyAuditRepository();
|
||||
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
|
||||
|
||||
const string invalidYaml = "version: '1.0'\nrules: []";
|
||||
var content = new PolicySnapshotContent(invalidYaml, PolicyDocumentFormat.Yaml, null, null, null);
|
||||
|
||||
var result = await store.SaveAsync(content, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.False(result.Created);
|
||||
Assert.Null(result.Snapshot);
|
||||
|
||||
var audits = await auditRepo.ListAsync(5);
|
||||
Assert.Empty(audits);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user