up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,524 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.RiskProfile.Lifecycle;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class RiskProfileEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRiskProfiles(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/risk/profiles")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Risk Profiles");
|
||||
|
||||
group.MapGet(string.Empty, ListProfiles)
|
||||
.WithName("ListRiskProfiles")
|
||||
.WithSummary("List all available risk profiles.")
|
||||
.Produces<RiskProfileListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapGet("/{profileId}", GetProfile)
|
||||
.WithName("GetRiskProfile")
|
||||
.WithSummary("Get a risk profile by ID.")
|
||||
.Produces<RiskProfileResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{profileId}/versions", ListVersions)
|
||||
.WithName("ListRiskProfileVersions")
|
||||
.WithSummary("List all versions of a risk profile.")
|
||||
.Produces<RiskProfileVersionListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapGet("/{profileId}/versions/{version}", GetVersion)
|
||||
.WithName("GetRiskProfileVersion")
|
||||
.WithSummary("Get a specific version of a risk profile.")
|
||||
.Produces<RiskProfileResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost(string.Empty, CreateProfile)
|
||||
.WithName("CreateRiskProfile")
|
||||
.WithSummary("Create a new risk profile version in draft status.")
|
||||
.Produces<RiskProfileResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/{profileId}/versions/{version}:activate", ActivateProfile)
|
||||
.WithName("ActivateRiskProfile")
|
||||
.WithSummary("Activate a draft risk profile, making it available for use.")
|
||||
.Produces<RiskProfileVersionInfoResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{profileId}/versions/{version}:deprecate", DeprecateProfile)
|
||||
.WithName("DeprecateRiskProfile")
|
||||
.WithSummary("Deprecate an active risk profile.")
|
||||
.Produces<RiskProfileVersionInfoResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{profileId}/versions/{version}:archive", ArchiveProfile)
|
||||
.WithName("ArchiveRiskProfile")
|
||||
.WithSummary("Archive a risk profile, removing it from active use.")
|
||||
.Produces<RiskProfileVersionInfoResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{profileId}/events", GetProfileEvents)
|
||||
.WithName("GetRiskProfileEvents")
|
||||
.WithSummary("Get lifecycle events for a risk profile.")
|
||||
.Produces<RiskProfileEventListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/compare", CompareProfiles)
|
||||
.WithName("CompareRiskProfiles")
|
||||
.WithSummary("Compare two risk profile versions and list differences.")
|
||||
.Produces<RiskProfileComparisonResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapGet("/{profileId}/hash", GetProfileHash)
|
||||
.WithName("GetRiskProfileHash")
|
||||
.WithSummary("Get the deterministic hash of a risk profile.")
|
||||
.Produces<RiskProfileHashResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult ListProfiles(
|
||||
HttpContext context,
|
||||
RiskProfileConfigurationService profileService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var ids = profileService.GetProfileIds();
|
||||
var profiles = ids
|
||||
.Select(id => profileService.GetProfile(id))
|
||||
.Where(p => p != null)
|
||||
.Select(p => new RiskProfileSummary(p!.Id, p.Version, p.Description))
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new RiskProfileListResponse(profiles));
|
||||
}
|
||||
|
||||
private static IResult GetProfile(
|
||||
HttpContext context,
|
||||
[FromRoute] string profileId,
|
||||
RiskProfileConfigurationService profileService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var profile = profileService.GetProfile(profileId);
|
||||
if (profile == null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = $"Risk profile '{profileId}' was not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var hash = profileService.ComputeHash(profile);
|
||||
return Results.Ok(new RiskProfileResponse(profile, hash));
|
||||
}
|
||||
|
||||
private static IResult ListVersions(
|
||||
HttpContext context,
|
||||
[FromRoute] string profileId,
|
||||
RiskProfileLifecycleService lifecycleService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var versions = lifecycleService.GetAllVersions(profileId);
|
||||
return Results.Ok(new RiskProfileVersionListResponse(profileId, versions));
|
||||
}
|
||||
|
||||
private static IResult GetVersion(
|
||||
HttpContext context,
|
||||
[FromRoute] string profileId,
|
||||
[FromRoute] string version,
|
||||
RiskProfileConfigurationService profileService,
|
||||
RiskProfileLifecycleService lifecycleService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var versionInfo = lifecycleService.GetVersionInfo(profileId, version);
|
||||
if (versionInfo == null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Version not found",
|
||||
Detail = $"Risk profile '{profileId}' version '{version}' was not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var profile = profileService.GetProfile(profileId);
|
||||
if (profile == null || profile.Version != version)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = $"Risk profile '{profileId}' version '{version}' content not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var hash = profileService.ComputeHash(profile);
|
||||
return Results.Ok(new RiskProfileResponse(profile, hash, versionInfo));
|
||||
}
|
||||
|
||||
private static IResult CreateProfile(
|
||||
HttpContext context,
|
||||
[FromBody] CreateRiskProfileRequest request,
|
||||
RiskProfileConfigurationService profileService,
|
||||
RiskProfileLifecycleService lifecycleService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request?.Profile == null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Profile definition is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
|
||||
try
|
||||
{
|
||||
var profile = request.Profile;
|
||||
profileService.RegisterProfile(profile);
|
||||
|
||||
var versionInfo = lifecycleService.CreateVersion(profile, actorId);
|
||||
var hash = profileService.ComputeHash(profile);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/risk/profiles/{profile.Id}/versions/{profile.Version}",
|
||||
new RiskProfileResponse(profile, hash, versionInfo));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Profile creation failed",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult ActivateProfile(
|
||||
HttpContext context,
|
||||
[FromRoute] string profileId,
|
||||
[FromRoute] string version,
|
||||
RiskProfileLifecycleService lifecycleService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
|
||||
try
|
||||
{
|
||||
var versionInfo = lifecycleService.Activate(profileId, version, actorId);
|
||||
return Results.Ok(new RiskProfileVersionInfoResponse(versionInfo));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Activation failed",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult DeprecateProfile(
|
||||
HttpContext context,
|
||||
[FromRoute] string profileId,
|
||||
[FromRoute] string version,
|
||||
[FromBody] DeprecateRiskProfileRequest? request,
|
||||
RiskProfileLifecycleService lifecycleService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
|
||||
try
|
||||
{
|
||||
var versionInfo = lifecycleService.Deprecate(
|
||||
profileId,
|
||||
version,
|
||||
request?.SuccessorVersion,
|
||||
request?.Reason,
|
||||
actorId);
|
||||
|
||||
return Results.Ok(new RiskProfileVersionInfoResponse(versionInfo));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Deprecation failed",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult ArchiveProfile(
|
||||
HttpContext context,
|
||||
[FromRoute] string profileId,
|
||||
[FromRoute] string version,
|
||||
RiskProfileLifecycleService lifecycleService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
|
||||
try
|
||||
{
|
||||
var versionInfo = lifecycleService.Archive(profileId, version, actorId);
|
||||
return Results.Ok(new RiskProfileVersionInfoResponse(versionInfo));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult GetProfileEvents(
|
||||
HttpContext context,
|
||||
[FromRoute] string profileId,
|
||||
[FromQuery] int limit,
|
||||
RiskProfileLifecycleService lifecycleService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var effectiveLimit = limit > 0 ? limit : 100;
|
||||
var events = lifecycleService.GetEvents(profileId, effectiveLimit);
|
||||
return Results.Ok(new RiskProfileEventListResponse(profileId, events));
|
||||
}
|
||||
|
||||
private static IResult CompareProfiles(
|
||||
HttpContext context,
|
||||
[FromBody] CompareRiskProfilesRequest request,
|
||||
RiskProfileConfigurationService profileService,
|
||||
RiskProfileLifecycleService lifecycleService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null ||
|
||||
string.IsNullOrWhiteSpace(request.FromProfileId) ||
|
||||
string.IsNullOrWhiteSpace(request.FromVersion) ||
|
||||
string.IsNullOrWhiteSpace(request.ToProfileId) ||
|
||||
string.IsNullOrWhiteSpace(request.ToVersion))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Both from and to profile IDs and versions are required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var fromProfile = profileService.GetProfile(request.FromProfileId);
|
||||
var toProfile = profileService.GetProfile(request.ToProfileId);
|
||||
|
||||
if (fromProfile == null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = $"From profile '{request.FromProfileId}' was not found.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (toProfile == null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = $"To profile '{request.ToProfileId}' was not found.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var comparison = lifecycleService.CompareVersions(fromProfile, toProfile);
|
||||
return Results.Ok(new RiskProfileComparisonResponse(comparison));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Comparison failed",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult GetProfileHash(
|
||||
HttpContext context,
|
||||
[FromRoute] string profileId,
|
||||
[FromQuery] bool contentOnly,
|
||||
RiskProfileConfigurationService profileService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var profile = profileService.GetProfile(profileId);
|
||||
if (profile == null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = $"Risk profile '{profileId}' was not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var hash = contentOnly
|
||||
? profileService.ComputeContentHash(profile)
|
||||
: profileService.ComputeHash(profile);
|
||||
|
||||
return Results.Ok(new RiskProfileHashResponse(profile.Id, profile.Version, hash, contentOnly));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
#region Request/Response DTOs
|
||||
|
||||
internal sealed record RiskProfileListResponse(IReadOnlyList<RiskProfileSummary> Profiles);
|
||||
|
||||
internal sealed record RiskProfileSummary(string ProfileId, string Version, string? Description);
|
||||
|
||||
internal sealed record RiskProfileResponse(
|
||||
RiskProfileModel Profile,
|
||||
string Hash,
|
||||
RiskProfileVersionInfo? VersionInfo = null);
|
||||
|
||||
internal sealed record RiskProfileVersionListResponse(
|
||||
string ProfileId,
|
||||
IReadOnlyList<RiskProfileVersionInfo> Versions);
|
||||
|
||||
internal sealed record RiskProfileVersionInfoResponse(RiskProfileVersionInfo VersionInfo);
|
||||
|
||||
internal sealed record RiskProfileEventListResponse(
|
||||
string ProfileId,
|
||||
IReadOnlyList<RiskProfileLifecycleEvent> Events);
|
||||
|
||||
internal sealed record RiskProfileComparisonResponse(RiskProfileVersionComparison Comparison);
|
||||
|
||||
internal sealed record RiskProfileHashResponse(
|
||||
string ProfileId,
|
||||
string Version,
|
||||
string Hash,
|
||||
bool ContentOnly);
|
||||
|
||||
internal sealed record CreateRiskProfileRequest(RiskProfileModel Profile);
|
||||
|
||||
internal sealed record DeprecateRiskProfileRequest(string? SuccessorVersion, string? Reason);
|
||||
|
||||
internal sealed record CompareRiskProfilesRequest(
|
||||
string FromProfileId,
|
||||
string FromVersion,
|
||||
string ToProfileId,
|
||||
string ToVersion);
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using StellaOps.Policy.RiskProfile.Schema;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class RiskProfileSchemaEndpoints
|
||||
{
|
||||
private const string JsonSchemaMediaType = "application/schema+json";
|
||||
|
||||
public static IEndpointRouteBuilder MapRiskProfileSchema(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapGet("/.well-known/risk-profile-schema", GetSchema)
|
||||
.WithName("GetRiskProfileSchema")
|
||||
.WithSummary("Get the JSON Schema for risk profile definitions.")
|
||||
.WithTags("Schema Discovery")
|
||||
.Produces<string>(StatusCodes.Status200OK, contentType: JsonSchemaMediaType)
|
||||
.Produces(StatusCodes.Status304NotModified)
|
||||
.AllowAnonymous();
|
||||
|
||||
endpoints.MapPost("/api/risk/schema/validate", ValidateProfile)
|
||||
.WithName("ValidateRiskProfile")
|
||||
.WithSummary("Validate a risk profile document against the schema.")
|
||||
.WithTags("Schema Validation")
|
||||
.Produces<RiskProfileValidationResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult GetSchema(HttpContext context)
|
||||
{
|
||||
var schemaText = RiskProfileSchemaProvider.GetSchemaText();
|
||||
var etag = RiskProfileSchemaProvider.GetETag();
|
||||
var version = RiskProfileSchemaProvider.GetSchemaVersion();
|
||||
|
||||
context.Response.Headers[HeaderNames.ETag] = etag;
|
||||
context.Response.Headers[HeaderNames.CacheControl] = "public, max-age=86400";
|
||||
context.Response.Headers["X-StellaOps-Schema-Version"] = version;
|
||||
|
||||
var ifNoneMatch = context.Request.Headers[HeaderNames.IfNoneMatch].ToString();
|
||||
if (!string.IsNullOrEmpty(ifNoneMatch) && ifNoneMatch.Contains(etag.Trim('"')))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status304NotModified);
|
||||
}
|
||||
|
||||
return Results.Text(schemaText, JsonSchemaMediaType);
|
||||
}
|
||||
|
||||
private static IResult ValidateProfile(
|
||||
HttpContext context,
|
||||
[FromBody] JsonElement profileDocument)
|
||||
{
|
||||
if (profileDocument.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Profile document is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var schema = RiskProfileSchemaProvider.GetSchema();
|
||||
var jsonText = profileDocument.GetRawText();
|
||||
|
||||
var result = schema.Evaluate(System.Text.Json.Nodes.JsonNode.Parse(jsonText));
|
||||
var issues = new List<RiskProfileValidationIssue>();
|
||||
|
||||
if (!result.IsValid)
|
||||
{
|
||||
CollectValidationIssues(result, issues);
|
||||
}
|
||||
|
||||
return Results.Ok(new RiskProfileValidationResponse(
|
||||
IsValid: result.IsValid,
|
||||
SchemaVersion: RiskProfileSchemaProvider.GetSchemaVersion(),
|
||||
Issues: issues));
|
||||
}
|
||||
|
||||
private static void CollectValidationIssues(
|
||||
Json.Schema.EvaluationResults results,
|
||||
List<RiskProfileValidationIssue> issues,
|
||||
string path = "")
|
||||
{
|
||||
if (results.Errors is not null)
|
||||
{
|
||||
foreach (var (key, message) in results.Errors)
|
||||
{
|
||||
var instancePath = results.InstanceLocation?.ToString() ?? path;
|
||||
issues.Add(new RiskProfileValidationIssue(
|
||||
Path: instancePath,
|
||||
Error: key,
|
||||
Message: message));
|
||||
}
|
||||
}
|
||||
|
||||
if (results.Details is not null)
|
||||
{
|
||||
foreach (var detail in results.Details)
|
||||
{
|
||||
if (!detail.IsValid)
|
||||
{
|
||||
CollectValidationIssues(detail, issues, detail.InstanceLocation?.ToString() ?? path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RiskProfileValidationResponse(
|
||||
bool IsValid,
|
||||
string SchemaVersion,
|
||||
IReadOnlyList<RiskProfileValidationIssue> Issues);
|
||||
|
||||
internal sealed record RiskProfileValidationIssue(
|
||||
string Path,
|
||||
string Error,
|
||||
string Message);
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Options;
|
||||
|
||||
@@ -22,6 +23,10 @@ public sealed class PolicyEngineOptions
|
||||
|
||||
public PolicyEngineActivationOptions Activation { get; } = new();
|
||||
|
||||
public PolicyEngineTelemetryOptions Telemetry { get; } = new();
|
||||
|
||||
public PolicyEngineRiskProfileOptions RiskProfile { get; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
Authority.Validate();
|
||||
@@ -30,6 +35,8 @@ public sealed class PolicyEngineOptions
|
||||
ResourceServer.Validate();
|
||||
Compilation.Validate();
|
||||
Activation.Validate();
|
||||
Telemetry.Validate();
|
||||
RiskProfile.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,3 +232,131 @@ public sealed class PolicyEngineActivationOptions
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineRiskProfileOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enables risk profile integration for policy evaluation.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default profile ID to use when no profile is specified.
|
||||
/// </summary>
|
||||
public string DefaultProfileId { get; set; } = "default";
|
||||
|
||||
/// <summary>
|
||||
/// Directory containing risk profile JSON files.
|
||||
/// </summary>
|
||||
public string? ProfileDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum inheritance depth for profile resolution.
|
||||
/// </summary>
|
||||
public int MaxInheritanceDepth { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate profiles against the JSON schema on load.
|
||||
/// </summary>
|
||||
public bool ValidateOnLoad { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to cache resolved profiles in memory.
|
||||
/// </summary>
|
||||
public bool CacheResolvedProfiles { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Inline profile definitions (for config-based profiles).
|
||||
/// </summary>
|
||||
public List<RiskProfileDefinition> Profiles { get; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (MaxInheritanceDepth <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("RiskProfile.MaxInheritanceDepth must be greater than zero.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DefaultProfileId))
|
||||
{
|
||||
throw new InvalidOperationException("RiskProfile.DefaultProfileId is required.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inline risk profile definition in configuration.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// Profile identifier.
|
||||
/// </summary>
|
||||
public required string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Profile version (SemVer).
|
||||
/// </summary>
|
||||
public required string Version { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description.
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent profile ID for inheritance.
|
||||
/// </summary>
|
||||
public string? Extends { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signal definitions for risk scoring.
|
||||
/// </summary>
|
||||
public List<RiskProfileSignalDefinition> Signals { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Weight per signal name.
|
||||
/// </summary>
|
||||
public Dictionary<string, double> Weights { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata.
|
||||
/// </summary>
|
||||
public Dictionary<string, object?>? Metadata { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inline signal definition in configuration.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileSignalDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// Signal name.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signal source.
|
||||
/// </summary>
|
||||
public required string Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signal type (boolean, numeric, categorical).
|
||||
/// </summary>
|
||||
public required string Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON Pointer path in evidence.
|
||||
/// </summary>
|
||||
public string? Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional transform expression.
|
||||
/// </summary>
|
||||
public string? Transform { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional unit for numeric signals.
|
||||
/// </summary>
|
||||
public string? Unit { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 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;
|
||||
@@ -12,6 +12,7 @@ using StellaOps.Policy.Engine.Endpoints;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Workers;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
@@ -33,17 +34,17 @@ var policyEngineActivationConfigFiles = new[]
|
||||
"policy-engine.activation.yaml",
|
||||
"policy-engine.activation.local.yaml"
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
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 policyEngineConfigFiles)
|
||||
{
|
||||
var path = Path.Combine(contentRoot, relative);
|
||||
@@ -59,12 +60,12 @@ builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
});
|
||||
|
||||
var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyEngineOptions>(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "STELLAOPS_POLICY_ENGINE_";
|
||||
options.BindingSection = PolicyEngineOptions.SectionName;
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "STELLAOPS_POLICY_ENGINE_";
|
||||
options.BindingSection = PolicyEngineOptions.SectionName;
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
foreach (var relative in policyEngineConfigFiles)
|
||||
{
|
||||
var path = Path.Combine(builder.Environment.ContentRootPath, relative);
|
||||
@@ -79,33 +80,44 @@ var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyEngineOptions>(op
|
||||
};
|
||||
options.PostBind = static (value, _) => value.Validate();
|
||||
});
|
||||
|
||||
|
||||
builder.Configuration.AddConfiguration(bootstrap.Configuration);
|
||||
|
||||
builder.ConfigurePolicyEngineTelemetry(bootstrap.Options);
|
||||
|
||||
builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
|
||||
|
||||
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);
|
||||
.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.AddSingleton<PolicyTimelineEvents>();
|
||||
builder.Services.AddSingleton<EvidenceBundleService>();
|
||||
builder.Services.AddSingleton<PolicyEvaluationAttestationService>();
|
||||
builder.Services.AddSingleton<IncidentModeService>();
|
||||
builder.Services.AddSingleton<RiskProfileConfigurationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Lifecycle.RiskProfileLifecycleService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.IRiskScoringJobStore, StellaOps.Policy.Engine.Scoring.InMemoryRiskScoringJobStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.RiskScoringTriggerService>();
|
||||
builder.Services.AddHostedService<IncidentModeExpirationWorker>();
|
||||
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
|
||||
builder.Services.AddSingleton<PolicyCompiler>();
|
||||
builder.Services.AddSingleton<PolicyCompilationService>();
|
||||
@@ -137,36 +149,36 @@ 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();
|
||||
|
||||
|
||||
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
|
||||
@@ -188,5 +200,7 @@ app.MapPolicyWorker();
|
||||
app.MapLedgerExport();
|
||||
app.MapSnapshots();
|
||||
app.MapViolations();
|
||||
app.MapRiskProfiles();
|
||||
app.MapRiskProfileSchema();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Store for risk scoring jobs.
|
||||
/// </summary>
|
||||
public interface IRiskScoringJobStore
|
||||
{
|
||||
Task SaveAsync(RiskScoringJob job, CancellationToken cancellationToken = default);
|
||||
Task<RiskScoringJob?> GetAsync(string jobId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<RiskScoringJob>> ListByStatusAsync(RiskScoringJobStatus status, int limit = 100, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<RiskScoringJob>> ListByTenantAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default);
|
||||
Task UpdateStatusAsync(string jobId, RiskScoringJobStatus status, string? errorMessage = null, CancellationToken cancellationToken = default);
|
||||
Task<RiskScoringJob?> DequeueNextAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of risk scoring job store.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRiskScoringJobStore : IRiskScoringJobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, RiskScoringJob> _jobs = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryRiskScoringJobStore(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public Task SaveAsync(RiskScoringJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_jobs[job.JobId] = job;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<RiskScoringJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_jobs.TryGetValue(jobId, out var job);
|
||||
return Task.FromResult(job);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RiskScoringJob>> ListByStatusAsync(RiskScoringJobStatus status, int limit = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var jobs = _jobs.Values
|
||||
.Where(j => j.Status == status)
|
||||
.OrderBy(j => j.RequestedAt)
|
||||
.Take(limit)
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<RiskScoringJob>>(jobs);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RiskScoringJob>> ListByTenantAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var jobs = _jobs.Values
|
||||
.Where(j => j.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(j => j.RequestedAt)
|
||||
.Take(limit)
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<RiskScoringJob>>(jobs);
|
||||
}
|
||||
|
||||
public Task UpdateStatusAsync(string jobId, RiskScoringJobStatus status, string? errorMessage = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_jobs.TryGetValue(jobId, out var job))
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = job with
|
||||
{
|
||||
Status = status,
|
||||
StartedAt = status == RiskScoringJobStatus.Running ? now : job.StartedAt,
|
||||
CompletedAt = status is RiskScoringJobStatus.Completed or RiskScoringJobStatus.Failed or RiskScoringJobStatus.Cancelled ? now : job.CompletedAt,
|
||||
ErrorMessage = errorMessage ?? job.ErrorMessage
|
||||
};
|
||||
_jobs[jobId] = updated;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<RiskScoringJob?> DequeueNextAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var next = _jobs.Values
|
||||
.Where(j => j.Status == RiskScoringJobStatus.Queued)
|
||||
.OrderByDescending(j => j.Priority)
|
||||
.ThenBy(j => j.RequestedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (next != null)
|
||||
{
|
||||
var running = next with
|
||||
{
|
||||
Status = RiskScoringJobStatus.Running,
|
||||
StartedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
_jobs[next.JobId] = running;
|
||||
return Task.FromResult<RiskScoringJob?>(running);
|
||||
}
|
||||
|
||||
return Task.FromResult<RiskScoringJob?>(null);
|
||||
}
|
||||
}
|
||||
131
src/Policy/StellaOps.Policy.Engine/Scoring/RiskScoringModels.cs
Normal file
131
src/Policy/StellaOps.Policy.Engine/Scoring/RiskScoringModels.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Event indicating a finding has been created or updated.
|
||||
/// </summary>
|
||||
public sealed record FindingChangedEvent(
|
||||
[property: JsonPropertyName("finding_id")] string FindingId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("change_type")] FindingChangeType ChangeType,
|
||||
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
|
||||
[property: JsonPropertyName("correlation_id")] string? CorrelationId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Type of finding change.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<FindingChangeType>))]
|
||||
public enum FindingChangeType
|
||||
{
|
||||
[JsonPropertyName("created")]
|
||||
Created,
|
||||
|
||||
[JsonPropertyName("updated")]
|
||||
Updated,
|
||||
|
||||
[JsonPropertyName("enriched")]
|
||||
Enriched,
|
||||
|
||||
[JsonPropertyName("vex_applied")]
|
||||
VexApplied
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a risk scoring job.
|
||||
/// </summary>
|
||||
public sealed record RiskScoringJobRequest(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("findings")] IReadOnlyList<RiskScoringFinding> Findings,
|
||||
[property: JsonPropertyName("priority")] RiskScoringPriority Priority = RiskScoringPriority.Normal,
|
||||
[property: JsonPropertyName("correlation_id")] string? CorrelationId = null,
|
||||
[property: JsonPropertyName("requested_at")] DateTimeOffset? RequestedAt = null);
|
||||
|
||||
/// <summary>
|
||||
/// A finding to score.
|
||||
/// </summary>
|
||||
public sealed record RiskScoringFinding(
|
||||
[property: JsonPropertyName("finding_id")] string FindingId,
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("trigger")] FindingChangeType Trigger);
|
||||
|
||||
/// <summary>
|
||||
/// Priority for risk scoring jobs.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskScoringPriority>))]
|
||||
public enum RiskScoringPriority
|
||||
{
|
||||
[JsonPropertyName("low")]
|
||||
Low,
|
||||
|
||||
[JsonPropertyName("normal")]
|
||||
Normal,
|
||||
|
||||
[JsonPropertyName("high")]
|
||||
High,
|
||||
|
||||
[JsonPropertyName("emergency")]
|
||||
Emergency
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A queued or completed risk scoring job.
|
||||
/// </summary>
|
||||
public sealed record RiskScoringJob(
|
||||
[property: JsonPropertyName("job_id")] string JobId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("profile_hash")] string ProfileHash,
|
||||
[property: JsonPropertyName("findings")] IReadOnlyList<RiskScoringFinding> Findings,
|
||||
[property: JsonPropertyName("priority")] RiskScoringPriority Priority,
|
||||
[property: JsonPropertyName("status")] RiskScoringJobStatus Status,
|
||||
[property: JsonPropertyName("requested_at")] DateTimeOffset RequestedAt,
|
||||
[property: JsonPropertyName("started_at")] DateTimeOffset? StartedAt = null,
|
||||
[property: JsonPropertyName("completed_at")] DateTimeOffset? CompletedAt = null,
|
||||
[property: JsonPropertyName("correlation_id")] string? CorrelationId = null,
|
||||
[property: JsonPropertyName("error_message")] string? ErrorMessage = null);
|
||||
|
||||
/// <summary>
|
||||
/// Status of a risk scoring job.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskScoringJobStatus>))]
|
||||
public enum RiskScoringJobStatus
|
||||
{
|
||||
[JsonPropertyName("queued")]
|
||||
Queued,
|
||||
|
||||
[JsonPropertyName("running")]
|
||||
Running,
|
||||
|
||||
[JsonPropertyName("completed")]
|
||||
Completed,
|
||||
|
||||
[JsonPropertyName("failed")]
|
||||
Failed,
|
||||
|
||||
[JsonPropertyName("cancelled")]
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of scoring a single finding.
|
||||
/// </summary>
|
||||
public sealed record RiskScoringResult(
|
||||
[property: JsonPropertyName("finding_id")] string FindingId,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("profile_version")] string ProfileVersion,
|
||||
[property: JsonPropertyName("raw_score")] double RawScore,
|
||||
[property: JsonPropertyName("normalized_score")] double NormalizedScore,
|
||||
[property: JsonPropertyName("severity")] string Severity,
|
||||
[property: JsonPropertyName("signal_values")] IReadOnlyDictionary<string, object?> SignalValues,
|
||||
[property: JsonPropertyName("signal_contributions")] IReadOnlyDictionary<string, double> SignalContributions,
|
||||
[property: JsonPropertyName("override_applied")] string? OverrideApplied,
|
||||
[property: JsonPropertyName("override_reason")] string? OverrideReason,
|
||||
[property: JsonPropertyName("scored_at")] DateTimeOffset ScoredAt);
|
||||
@@ -0,0 +1,265 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
using StellaOps.Policy.RiskProfile.Hashing;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Service for triggering risk scoring jobs when findings change.
|
||||
/// </summary>
|
||||
public sealed class RiskScoringTriggerService
|
||||
{
|
||||
private readonly ILogger<RiskScoringTriggerService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly RiskProfileConfigurationService _profileService;
|
||||
private readonly IRiskScoringJobStore _jobStore;
|
||||
private readonly RiskProfileHasher _hasher;
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _recentTriggers;
|
||||
private readonly TimeSpan _deduplicationWindow;
|
||||
|
||||
public RiskScoringTriggerService(
|
||||
ILogger<RiskScoringTriggerService> logger,
|
||||
TimeProvider timeProvider,
|
||||
RiskProfileConfigurationService profileService,
|
||||
IRiskScoringJobStore jobStore)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_profileService = profileService ?? throw new ArgumentNullException(nameof(profileService));
|
||||
_jobStore = jobStore ?? throw new ArgumentNullException(nameof(jobStore));
|
||||
_hasher = new RiskProfileHasher();
|
||||
_recentTriggers = new ConcurrentDictionary<string, DateTimeOffset>();
|
||||
_deduplicationWindow = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a finding changed event and creates a scoring job if appropriate.
|
||||
/// </summary>
|
||||
/// <param name="evt">The finding changed event.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created job, or null if skipped.</returns>
|
||||
public async Task<RiskScoringJob?> HandleFindingChangedAsync(
|
||||
FindingChangedEvent evt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
|
||||
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("risk_scoring.trigger");
|
||||
activity?.SetTag("finding.id", evt.FindingId);
|
||||
activity?.SetTag("change_type", evt.ChangeType.ToString());
|
||||
|
||||
if (!_profileService.IsEnabled)
|
||||
{
|
||||
_logger.LogDebug("Risk profile integration disabled; skipping scoring for {FindingId}", evt.FindingId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var triggerKey = BuildTriggerKey(evt);
|
||||
if (IsRecentlyTriggered(triggerKey))
|
||||
{
|
||||
_logger.LogDebug("Skipping duplicate trigger for {FindingId} within deduplication window", evt.FindingId);
|
||||
PolicyEngineTelemetry.RiskScoringTriggersSkipped.Add(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
var request = new RiskScoringJobRequest(
|
||||
TenantId: evt.TenantId,
|
||||
ContextId: evt.ContextId,
|
||||
ProfileId: _profileService.DefaultProfileId,
|
||||
Findings: new[]
|
||||
{
|
||||
new RiskScoringFinding(
|
||||
evt.FindingId,
|
||||
evt.ComponentPurl,
|
||||
evt.AdvisoryId,
|
||||
evt.ChangeType)
|
||||
},
|
||||
Priority: DeterminePriority(evt.ChangeType),
|
||||
CorrelationId: evt.CorrelationId,
|
||||
RequestedAt: evt.Timestamp);
|
||||
|
||||
var job = await CreateJobAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
RecordTrigger(triggerKey);
|
||||
PolicyEngineTelemetry.RiskScoringJobsCreated.Add(1);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created risk scoring job {JobId} for finding {FindingId} (trigger: {ChangeType})",
|
||||
job.JobId, evt.FindingId, evt.ChangeType);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles multiple finding changed events in batch.
|
||||
/// </summary>
|
||||
/// <param name="events">The finding changed events.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created job, or null if all events were skipped.</returns>
|
||||
public async Task<RiskScoringJob?> HandleFindingsBatchAsync(
|
||||
IReadOnlyList<FindingChangedEvent> events,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_profileService.IsEnabled)
|
||||
{
|
||||
_logger.LogDebug("Risk profile integration disabled; skipping batch scoring");
|
||||
return null;
|
||||
}
|
||||
|
||||
var uniqueEvents = events
|
||||
.Where(e => !IsRecentlyTriggered(BuildTriggerKey(e)))
|
||||
.GroupBy(e => e.FindingId)
|
||||
.Select(g => g.OrderByDescending(e => e.Timestamp).First())
|
||||
.ToList();
|
||||
|
||||
if (uniqueEvents.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("All events in batch were duplicates; skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
var firstEvent = uniqueEvents[0];
|
||||
var highestPriority = uniqueEvents.Select(e => DeterminePriority(e.ChangeType)).Max();
|
||||
|
||||
var request = new RiskScoringJobRequest(
|
||||
TenantId: firstEvent.TenantId,
|
||||
ContextId: firstEvent.ContextId,
|
||||
ProfileId: _profileService.DefaultProfileId,
|
||||
Findings: uniqueEvents.Select(e => new RiskScoringFinding(
|
||||
e.FindingId,
|
||||
e.ComponentPurl,
|
||||
e.AdvisoryId,
|
||||
e.ChangeType)).ToList(),
|
||||
Priority: highestPriority,
|
||||
CorrelationId: firstEvent.CorrelationId,
|
||||
RequestedAt: _timeProvider.GetUtcNow());
|
||||
|
||||
var job = await CreateJobAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var evt in uniqueEvents)
|
||||
{
|
||||
RecordTrigger(BuildTriggerKey(evt));
|
||||
}
|
||||
|
||||
PolicyEngineTelemetry.RiskScoringJobsCreated.Add(1);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created batch risk scoring job {JobId} for {FindingCount} findings",
|
||||
job.JobId, uniqueEvents.Count);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a risk scoring job from a request.
|
||||
/// </summary>
|
||||
public async Task<RiskScoringJob> CreateJobAsync(
|
||||
RiskScoringJobRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var profile = _profileService.GetProfile(request.ProfileId);
|
||||
if (profile == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Risk profile '{request.ProfileId}' not found.");
|
||||
}
|
||||
|
||||
var profileHash = _hasher.ComputeHash(profile);
|
||||
var requestedAt = request.RequestedAt ?? _timeProvider.GetUtcNow();
|
||||
var jobId = GenerateJobId(request.TenantId, request.ContextId, requestedAt);
|
||||
|
||||
var job = new RiskScoringJob(
|
||||
JobId: jobId,
|
||||
TenantId: request.TenantId,
|
||||
ContextId: request.ContextId,
|
||||
ProfileId: request.ProfileId,
|
||||
ProfileHash: profileHash,
|
||||
Findings: request.Findings,
|
||||
Priority: request.Priority,
|
||||
Status: RiskScoringJobStatus.Queued,
|
||||
RequestedAt: requestedAt,
|
||||
CorrelationId: request.CorrelationId);
|
||||
|
||||
await _jobStore.SaveAsync(job, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current queue depth.
|
||||
/// </summary>
|
||||
public async Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var queued = await _jobStore.ListByStatusAsync(RiskScoringJobStatus.Queued, limit: 10000, cancellationToken).ConfigureAwait(false);
|
||||
return queued.Count;
|
||||
}
|
||||
|
||||
private static RiskScoringPriority DeterminePriority(FindingChangeType changeType)
|
||||
{
|
||||
return changeType switch
|
||||
{
|
||||
FindingChangeType.Created => RiskScoringPriority.High,
|
||||
FindingChangeType.Enriched => RiskScoringPriority.High,
|
||||
FindingChangeType.VexApplied => RiskScoringPriority.High,
|
||||
FindingChangeType.Updated => RiskScoringPriority.Normal,
|
||||
_ => RiskScoringPriority.Normal
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildTriggerKey(FindingChangedEvent evt)
|
||||
{
|
||||
return $"{evt.TenantId}|{evt.ContextId}|{evt.FindingId}|{evt.ChangeType}";
|
||||
}
|
||||
|
||||
private bool IsRecentlyTriggered(string key)
|
||||
{
|
||||
if (_recentTriggers.TryGetValue(key, out var timestamp))
|
||||
{
|
||||
var elapsed = _timeProvider.GetUtcNow() - timestamp;
|
||||
return elapsed < _deduplicationWindow;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void RecordTrigger(string key)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
_recentTriggers[key] = now;
|
||||
|
||||
CleanupOldTriggers(now);
|
||||
}
|
||||
|
||||
private void CleanupOldTriggers(DateTimeOffset now)
|
||||
{
|
||||
var threshold = now - _deduplicationWindow * 2;
|
||||
var keysToRemove = _recentTriggers
|
||||
.Where(kvp => kvp.Value < threshold)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_recentTriggers.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateJobId(string tenantId, string contextId, DateTimeOffset timestamp)
|
||||
{
|
||||
var seed = $"{tenantId}|{contextId}|{timestamp:O}|{Guid.NewGuid()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||
return $"rsj-{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.RiskProfile.Hashing;
|
||||
using StellaOps.Policy.RiskProfile.Merge;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
using StellaOps.Policy.RiskProfile.Validation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for loading and providing risk profiles from configuration.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileConfigurationService
|
||||
{
|
||||
private readonly ILogger<RiskProfileConfigurationService> _logger;
|
||||
private readonly PolicyEngineRiskProfileOptions _options;
|
||||
private readonly RiskProfileMergeService _mergeService;
|
||||
private readonly RiskProfileHasher _hasher;
|
||||
private readonly RiskProfileValidator _validator;
|
||||
private readonly ConcurrentDictionary<string, RiskProfileModel> _profileCache;
|
||||
private readonly ConcurrentDictionary<string, RiskProfileModel> _resolvedCache;
|
||||
private readonly object _loadLock = new();
|
||||
private bool _loaded;
|
||||
|
||||
public RiskProfileConfigurationService(
|
||||
ILogger<RiskProfileConfigurationService> logger,
|
||||
IOptions<PolicyEngineOptions> options)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value.RiskProfile ?? throw new ArgumentNullException(nameof(options));
|
||||
_mergeService = new RiskProfileMergeService();
|
||||
_hasher = new RiskProfileHasher();
|
||||
_validator = new RiskProfileValidator();
|
||||
_profileCache = new ConcurrentDictionary<string, RiskProfileModel>(StringComparer.OrdinalIgnoreCase);
|
||||
_resolvedCache = new ConcurrentDictionary<string, RiskProfileModel>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether risk profile integration is enabled.
|
||||
/// </summary>
|
||||
public bool IsEnabled => _options.Enabled;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default profile ID.
|
||||
/// </summary>
|
||||
public string DefaultProfileId => _options.DefaultProfileId;
|
||||
|
||||
/// <summary>
|
||||
/// Loads all profiles from configuration and file system.
|
||||
/// </summary>
|
||||
public void LoadProfiles()
|
||||
{
|
||||
if (_loaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_loadLock)
|
||||
{
|
||||
if (_loaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LoadInlineProfiles();
|
||||
LoadFileProfiles();
|
||||
EnsureDefaultProfile();
|
||||
|
||||
_loaded = true;
|
||||
_logger.LogInformation(
|
||||
"Loaded {Count} risk profiles (default: {DefaultId})",
|
||||
_profileCache.Count,
|
||||
_options.DefaultProfileId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a profile by ID, resolving inheritance if needed.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID to retrieve.</param>
|
||||
/// <returns>The resolved profile, or null if not found.</returns>
|
||||
public RiskProfileModel? GetProfile(string? profileId)
|
||||
{
|
||||
var id = string.IsNullOrWhiteSpace(profileId) ? _options.DefaultProfileId : profileId;
|
||||
|
||||
if (_options.CacheResolvedProfiles && _resolvedCache.TryGetValue(id, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (!_profileCache.TryGetValue(id, out var profile))
|
||||
{
|
||||
_logger.LogWarning("Risk profile '{ProfileId}' not found", id);
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolved = _mergeService.ResolveInheritance(
|
||||
profile,
|
||||
LookupProfile,
|
||||
_options.MaxInheritanceDepth);
|
||||
|
||||
if (_options.CacheResolvedProfiles)
|
||||
{
|
||||
_resolvedCache.TryAdd(id, resolved);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default profile.
|
||||
/// </summary>
|
||||
public RiskProfileModel? GetDefaultProfile() => GetProfile(_options.DefaultProfileId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all loaded profile IDs.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> GetProfileIds() => _profileCache.Keys.ToList().AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic hash for a profile.
|
||||
/// </summary>
|
||||
public string ComputeHash(RiskProfileModel profile) => _hasher.ComputeHash(profile);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a content hash (ignoring identity fields) for a profile.
|
||||
/// </summary>
|
||||
public string ComputeContentHash(RiskProfileModel profile) => _hasher.ComputeContentHash(profile);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a profile programmatically.
|
||||
/// </summary>
|
||||
public void RegisterProfile(RiskProfileModel profile)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
_profileCache[profile.Id] = profile;
|
||||
_resolvedCache.TryRemove(profile.Id, out _);
|
||||
|
||||
_logger.LogDebug("Registered risk profile '{ProfileId}' v{Version}", profile.Id, profile.Version);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the resolved profile cache.
|
||||
/// </summary>
|
||||
public void ClearResolvedCache()
|
||||
{
|
||||
_resolvedCache.Clear();
|
||||
_logger.LogDebug("Cleared resolved profile cache");
|
||||
}
|
||||
|
||||
private RiskProfileModel? LookupProfile(string id) =>
|
||||
_profileCache.TryGetValue(id, out var profile) ? profile : null;
|
||||
|
||||
private void LoadInlineProfiles()
|
||||
{
|
||||
foreach (var definition in _options.Profiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var profile = ConvertFromDefinition(definition);
|
||||
_profileCache[profile.Id] = profile;
|
||||
_logger.LogDebug("Loaded inline profile '{ProfileId}' v{Version}", profile.Id, profile.Version);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load inline profile '{ProfileId}'", definition.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadFileProfiles()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ProfileDirectory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(_options.ProfileDirectory))
|
||||
{
|
||||
_logger.LogWarning("Risk profile directory not found: {Directory}", _options.ProfileDirectory);
|
||||
return;
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(_options.ProfileDirectory, "*.json", SearchOption.AllDirectories);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(file);
|
||||
|
||||
if (_options.ValidateOnLoad)
|
||||
{
|
||||
var validation = _validator.Validate(json);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Risk profile file '{File}' failed validation: {Errors}",
|
||||
file,
|
||||
string.Join("; ", validation.Message ?? "Unknown error"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var profile = JsonSerializer.Deserialize<RiskProfileModel>(json, JsonOptions);
|
||||
if (profile != null)
|
||||
{
|
||||
_profileCache[profile.Id] = profile;
|
||||
_logger.LogDebug("Loaded profile '{ProfileId}' from {File}", profile.Id, file);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load risk profile from '{File}'", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureDefaultProfile()
|
||||
{
|
||||
if (_profileCache.ContainsKey(_options.DefaultProfileId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var defaultProfile = CreateBuiltInDefaultProfile();
|
||||
_profileCache[defaultProfile.Id] = defaultProfile;
|
||||
_logger.LogDebug("Created built-in default profile '{ProfileId}'", defaultProfile.Id);
|
||||
}
|
||||
|
||||
private static RiskProfileModel CreateBuiltInDefaultProfile()
|
||||
{
|
||||
return new RiskProfileModel
|
||||
{
|
||||
Id = "default",
|
||||
Version = "1.0.0",
|
||||
Description = "Built-in default risk profile with standard vulnerability signals.",
|
||||
Signals = new List<RiskSignal>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "cvss_score",
|
||||
Source = "vulnerability",
|
||||
Type = RiskSignalType.Numeric,
|
||||
Path = "/cvss/baseScore",
|
||||
Unit = "score"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "kev",
|
||||
Source = "cisa",
|
||||
Type = RiskSignalType.Boolean,
|
||||
Path = "/kev/inCatalog"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "epss",
|
||||
Source = "first",
|
||||
Type = RiskSignalType.Numeric,
|
||||
Path = "/epss/probability",
|
||||
Unit = "probability"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "reachability",
|
||||
Source = "analysis",
|
||||
Type = RiskSignalType.Categorical,
|
||||
Path = "/reachability/status"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "exploit_available",
|
||||
Source = "exploit-db",
|
||||
Type = RiskSignalType.Boolean,
|
||||
Path = "/exploit/available"
|
||||
}
|
||||
},
|
||||
Weights = new Dictionary<string, double>
|
||||
{
|
||||
["cvss_score"] = 0.3,
|
||||
["kev"] = 0.25,
|
||||
["epss"] = 0.2,
|
||||
["reachability"] = 0.15,
|
||||
["exploit_available"] = 0.1
|
||||
},
|
||||
Overrides = new RiskOverrides(),
|
||||
Metadata = new Dictionary<string, object?>
|
||||
{
|
||||
["builtin"] = true,
|
||||
["created"] = DateTimeOffset.UtcNow.ToString("o")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static RiskProfileModel ConvertFromDefinition(RiskProfileDefinition definition)
|
||||
{
|
||||
return new RiskProfileModel
|
||||
{
|
||||
Id = definition.Id,
|
||||
Version = definition.Version,
|
||||
Description = definition.Description,
|
||||
Extends = definition.Extends,
|
||||
Signals = definition.Signals.Select(s => new RiskSignal
|
||||
{
|
||||
Name = s.Name,
|
||||
Source = s.Source,
|
||||
Type = ParseSignalType(s.Type),
|
||||
Path = s.Path,
|
||||
Transform = s.Transform,
|
||||
Unit = s.Unit
|
||||
}).ToList(),
|
||||
Weights = new Dictionary<string, double>(definition.Weights),
|
||||
Overrides = new RiskOverrides(),
|
||||
Metadata = definition.Metadata != null
|
||||
? new Dictionary<string, object?>(definition.Metadata)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static RiskSignalType ParseSignalType(string type)
|
||||
{
|
||||
return type.ToLowerInvariant() switch
|
||||
{
|
||||
"boolean" or "bool" => RiskSignalType.Boolean,
|
||||
"numeric" or "number" => RiskSignalType.Numeric,
|
||||
"categorical" or "category" => RiskSignalType.Categorical,
|
||||
_ => throw new ArgumentException($"Unknown signal type: {type}")
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
<?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>
|
||||
|
||||
<?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>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
@@ -17,6 +28,9 @@
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
|
||||
<ProjectReference Include="../../Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.csproj" />
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Policy.Engine.Tests" />
|
||||
|
||||
379
src/Policy/StellaOps.Policy.Engine/Telemetry/EvidenceBundle.cs
Normal file
379
src/Policy/StellaOps.Policy.Engine/Telemetry/EvidenceBundle.cs
Normal file
@@ -0,0 +1,379 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an evaluation evidence bundle containing all inputs, outputs,
|
||||
/// and metadata for a policy evaluation run.
|
||||
/// </summary>
|
||||
public sealed class EvidenceBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this evidence bundle.
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Run identifier this bundle is associated with.
|
||||
/// </summary>
|
||||
public required string RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public required string Tenant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy identifier.
|
||||
/// </summary>
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version.
|
||||
/// </summary>
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the bundle was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the bundle contents for integrity verification.
|
||||
/// </summary>
|
||||
public string? ContentHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Determinism hash from the evaluation run.
|
||||
/// </summary>
|
||||
public string? DeterminismHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Input references for the evaluation.
|
||||
/// </summary>
|
||||
public required EvidenceInputs Inputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output summary from the evaluation.
|
||||
/// </summary>
|
||||
public required EvidenceOutputs Outputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment and configuration metadata.
|
||||
/// </summary>
|
||||
public required EvidenceEnvironment Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Manifest listing all artifacts in the bundle.
|
||||
/// </summary>
|
||||
public required EvidenceManifest Manifest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// References to inputs used in the policy evaluation.
|
||||
/// </summary>
|
||||
public sealed class EvidenceInputs
|
||||
{
|
||||
/// <summary>
|
||||
/// SBOM document references with content hashes.
|
||||
/// </summary>
|
||||
public List<EvidenceArtifactRef> SbomRefs { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Advisory document references from Concelier.
|
||||
/// </summary>
|
||||
public List<EvidenceArtifactRef> AdvisoryRefs { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// VEX document references from Excititor.
|
||||
/// </summary>
|
||||
public List<EvidenceArtifactRef> VexRefs { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Reachability evidence references.
|
||||
/// </summary>
|
||||
public List<EvidenceArtifactRef> ReachabilityRefs { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack IR digest.
|
||||
/// </summary>
|
||||
public string? PolicyIrDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cursor positions for incremental evaluation.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Cursors { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of evaluation outputs.
|
||||
/// </summary>
|
||||
public sealed class EvidenceOutputs
|
||||
{
|
||||
/// <summary>
|
||||
/// Total findings evaluated.
|
||||
/// </summary>
|
||||
public int TotalFindings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Findings by verdict status.
|
||||
/// </summary>
|
||||
public Dictionary<string, int> FindingsByVerdict { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Findings by severity.
|
||||
/// </summary>
|
||||
public Dictionary<string, int> FindingsBySeverity { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Total rules evaluated.
|
||||
/// </summary>
|
||||
public int RulesEvaluated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total rules that fired.
|
||||
/// </summary>
|
||||
public int RulesFired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX overrides applied.
|
||||
/// </summary>
|
||||
public int VexOverridesApplied { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the evaluation in seconds.
|
||||
/// </summary>
|
||||
public double DurationSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the evaluation (success, failure, canceled).
|
||||
/// </summary>
|
||||
public required string Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error details if outcome is failure.
|
||||
/// </summary>
|
||||
public string? ErrorDetails { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Environment and configuration metadata for the evaluation.
|
||||
/// </summary>
|
||||
public sealed class EvidenceEnvironment
|
||||
{
|
||||
/// <summary>
|
||||
/// Policy Engine service version.
|
||||
/// </summary>
|
||||
public required string ServiceVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation mode (full, incremental, simulate).
|
||||
/// </summary>
|
||||
public required string Mode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether sealed/air-gapped mode was active.
|
||||
/// </summary>
|
||||
public bool SealedMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Host machine identifier.
|
||||
/// </summary>
|
||||
public string? HostId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trace ID for correlation.
|
||||
/// </summary>
|
||||
public string? TraceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Configuration snapshot relevant to the evaluation.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> ConfigSnapshot { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest listing all artifacts in the evidence bundle.
|
||||
/// </summary>
|
||||
public sealed class EvidenceManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Version of the manifest schema.
|
||||
/// </summary>
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// List of artifacts in the bundle.
|
||||
/// </summary>
|
||||
public List<EvidenceArtifact> Artifacts { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Adds an artifact to the manifest.
|
||||
/// </summary>
|
||||
public void AddArtifact(string name, string mediaType, long sizeBytes, string contentHash)
|
||||
{
|
||||
Artifacts.Add(new EvidenceArtifact
|
||||
{
|
||||
Name = name,
|
||||
MediaType = mediaType,
|
||||
SizeBytes = sizeBytes,
|
||||
ContentHash = contentHash,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an external artifact used as input.
|
||||
/// </summary>
|
||||
public sealed class EvidenceArtifactRef
|
||||
{
|
||||
/// <summary>
|
||||
/// URI or identifier for the artifact.
|
||||
/// </summary>
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hash (SHA-256) of the artifact.
|
||||
/// </summary>
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Media type of the artifact.
|
||||
/// </summary>
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the artifact was fetched.
|
||||
/// </summary>
|
||||
public DateTimeOffset? FetchedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An artifact included in the evidence bundle.
|
||||
/// </summary>
|
||||
public sealed class EvidenceArtifact
|
||||
{
|
||||
/// <summary>
|
||||
/// Name/path of the artifact within the bundle.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Media type of the artifact.
|
||||
/// </summary>
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes.
|
||||
/// </summary>
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 content hash.
|
||||
/// </summary>
|
||||
public required string ContentHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and managing evaluation evidence bundles.
|
||||
/// </summary>
|
||||
public sealed class EvidenceBundleService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public EvidenceBundleService(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new evidence bundle for a policy evaluation run.
|
||||
/// </summary>
|
||||
public EvidenceBundle CreateBundle(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string policyVersion,
|
||||
string mode,
|
||||
string serviceVersion,
|
||||
bool sealedMode = false,
|
||||
string? traceId = null)
|
||||
{
|
||||
var bundleId = GenerateBundleId(runId);
|
||||
|
||||
return new EvidenceBundle
|
||||
{
|
||||
BundleId = bundleId,
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
PolicyVersion = policyVersion,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Inputs = new EvidenceInputs(),
|
||||
Outputs = new EvidenceOutputs { Outcome = "pending" },
|
||||
Environment = new EvidenceEnvironment
|
||||
{
|
||||
ServiceVersion = serviceVersion,
|
||||
Mode = mode,
|
||||
SealedMode = sealedMode,
|
||||
TraceId = traceId,
|
||||
HostId = Environment.MachineName,
|
||||
},
|
||||
Manifest = new EvidenceManifest(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finalizes the bundle by computing the content hash.
|
||||
/// </summary>
|
||||
public void FinalizeBundle(EvidenceBundle bundle)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
var json = JsonSerializer.Serialize(bundle, EvidenceBundleJsonContext.Default.EvidenceBundle);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
bundle.ContentHash = Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the bundle to JSON.
|
||||
/// </summary>
|
||||
public string SerializeBundle(EvidenceBundle bundle)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
return JsonSerializer.Serialize(bundle, EvidenceBundleJsonContext.Default.EvidenceBundle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a bundle from JSON.
|
||||
/// </summary>
|
||||
public EvidenceBundle? DeserializeBundle(string json)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(json);
|
||||
return JsonSerializer.Deserialize(json, EvidenceBundleJsonContext.Default.EvidenceBundle);
|
||||
}
|
||||
|
||||
private static string GenerateBundleId(string runId)
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
return $"bundle-{runId}-{timestamp:x}";
|
||||
}
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(EvidenceBundle))]
|
||||
[JsonSerializable(typeof(EvidenceInputs))]
|
||||
[JsonSerializable(typeof(EvidenceOutputs))]
|
||||
[JsonSerializable(typeof(EvidenceEnvironment))]
|
||||
[JsonSerializable(typeof(EvidenceManifest))]
|
||||
[JsonSerializable(typeof(EvidenceArtifact))]
|
||||
[JsonSerializable(typeof(EvidenceArtifactRef))]
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
internal partial class EvidenceBundleJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
214
src/Policy/StellaOps.Policy.Engine/Telemetry/IncidentMode.cs
Normal file
214
src/Policy/StellaOps.Policy.Engine/Telemetry/IncidentMode.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing incident mode, which enables 100% trace sampling
|
||||
/// and extended retention during critical periods.
|
||||
/// </summary>
|
||||
public sealed class IncidentModeService
|
||||
{
|
||||
private readonly ILogger<IncidentModeService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOptionsMonitor<PolicyEngineTelemetryOptions> _optionsMonitor;
|
||||
|
||||
private volatile IncidentModeState _state = new(false, null, null, null);
|
||||
|
||||
public IncidentModeService(
|
||||
ILogger<IncidentModeService> logger,
|
||||
TimeProvider timeProvider,
|
||||
IOptionsMonitor<PolicyEngineTelemetryOptions> optionsMonitor)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
|
||||
// Initialize from configuration
|
||||
if (_optionsMonitor.CurrentValue.IncidentMode)
|
||||
{
|
||||
_state = new IncidentModeState(
|
||||
true,
|
||||
_timeProvider.GetUtcNow(),
|
||||
null,
|
||||
"configuration");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current incident mode state.
|
||||
/// </summary>
|
||||
public IncidentModeState State => _state;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether incident mode is currently active.
|
||||
/// </summary>
|
||||
public bool IsActive => _state.IsActive;
|
||||
|
||||
/// <summary>
|
||||
/// Enables incident mode.
|
||||
/// </summary>
|
||||
/// <param name="reason">Reason for enabling incident mode.</param>
|
||||
/// <param name="duration">Optional duration after which incident mode auto-disables.</param>
|
||||
public void Enable(string reason, TimeSpan? duration = null)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = duration.HasValue ? now.Add(duration.Value) : (DateTimeOffset?)null;
|
||||
|
||||
_state = new IncidentModeState(true, now, expiresAt, reason);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Incident mode ENABLED. Reason: {Reason}, ExpiresAt: {ExpiresAt}",
|
||||
reason,
|
||||
expiresAt?.ToString("O") ?? "never");
|
||||
|
||||
PolicyEngineTelemetry.RecordError("incident_mode_enabled", null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables incident mode.
|
||||
/// </summary>
|
||||
/// <param name="reason">Reason for disabling incident mode.</param>
|
||||
public void Disable(string reason)
|
||||
{
|
||||
var wasActive = _state.IsActive;
|
||||
_state = new IncidentModeState(false, null, null, null);
|
||||
|
||||
if (wasActive)
|
||||
{
|
||||
_logger.LogInformation("Incident mode DISABLED. Reason: {Reason}", reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if incident mode should be auto-disabled due to expiration.
|
||||
/// </summary>
|
||||
public void CheckExpiration()
|
||||
{
|
||||
var state = _state;
|
||||
if (state.IsActive && state.ExpiresAt.HasValue)
|
||||
{
|
||||
if (_timeProvider.GetUtcNow() >= state.ExpiresAt.Value)
|
||||
{
|
||||
Disable("auto-expired");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective sampling ratio, considering incident mode.
|
||||
/// </summary>
|
||||
public double GetEffectiveSamplingRatio()
|
||||
{
|
||||
if (_state.IsActive)
|
||||
{
|
||||
return 1.0; // 100% sampling during incident mode
|
||||
}
|
||||
|
||||
return _optionsMonitor.CurrentValue.TraceSamplingRatio;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the current state of incident mode.
|
||||
/// </summary>
|
||||
public sealed record IncidentModeState(
|
||||
bool IsActive,
|
||||
DateTimeOffset? ActivatedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string? Reason);
|
||||
|
||||
/// <summary>
|
||||
/// A trace sampler that respects incident mode settings.
|
||||
/// </summary>
|
||||
public sealed class IncidentModeSampler : Sampler
|
||||
{
|
||||
private readonly IncidentModeService _incidentModeService;
|
||||
private readonly Sampler _baseSampler;
|
||||
|
||||
public IncidentModeSampler(IncidentModeService incidentModeService, double baseSamplingRatio)
|
||||
{
|
||||
_incidentModeService = incidentModeService ?? throw new ArgumentNullException(nameof(incidentModeService));
|
||||
_baseSampler = new TraceIdRatioBasedSampler(baseSamplingRatio);
|
||||
}
|
||||
|
||||
public override SamplingResult ShouldSample(in SamplingParameters samplingParameters)
|
||||
{
|
||||
// During incident mode, always sample
|
||||
if (_incidentModeService.IsActive)
|
||||
{
|
||||
return new SamplingResult(
|
||||
SamplingDecision.RecordAndSample,
|
||||
samplingParameters.Tags,
|
||||
samplingParameters.Links);
|
||||
}
|
||||
|
||||
// Otherwise, use the base sampler
|
||||
return _baseSampler.ShouldSample(samplingParameters);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring incident mode.
|
||||
/// </summary>
|
||||
public static class IncidentModeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the incident mode sampler to the tracer provider.
|
||||
/// </summary>
|
||||
public static TracerProviderBuilder SetIncidentModeSampler(
|
||||
this TracerProviderBuilder builder,
|
||||
IncidentModeService incidentModeService,
|
||||
double baseSamplingRatio)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(incidentModeService);
|
||||
|
||||
return builder.SetSampler(new IncidentModeSampler(incidentModeService, baseSamplingRatio));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background service that periodically checks incident mode expiration.
|
||||
/// </summary>
|
||||
public sealed class IncidentModeExpirationWorker : BackgroundService
|
||||
{
|
||||
private readonly IncidentModeService _incidentModeService;
|
||||
private readonly ILogger<IncidentModeExpirationWorker> _logger;
|
||||
private readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
public IncidentModeExpirationWorker(
|
||||
IncidentModeService incidentModeService,
|
||||
ILogger<IncidentModeExpirationWorker> logger)
|
||||
{
|
||||
_incidentModeService = incidentModeService ?? throw new ArgumentNullException(nameof(incidentModeService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogDebug("Incident mode expiration worker started.");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_incidentModeService.CheckExpiration();
|
||||
await Task.Delay(_checkInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking incident mode expiration.");
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Incident mode expiration worker stopped.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry instrumentation for the Policy Engine service.
|
||||
/// Provides metrics, traces, and structured logging correlation.
|
||||
/// </summary>
|
||||
public static class PolicyEngineTelemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the meter used for Policy Engine metrics.
|
||||
/// </summary>
|
||||
public const string MeterName = "StellaOps.Policy.Engine";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the activity source used for Policy Engine traces.
|
||||
/// </summary>
|
||||
public const string ActivitySourceName = "StellaOps.Policy.Engine";
|
||||
|
||||
private static readonly Meter Meter = new(MeterName);
|
||||
|
||||
/// <summary>
|
||||
/// The activity source used for Policy Engine traces.
|
||||
/// </summary>
|
||||
public static readonly ActivitySource ActivitySource = new(ActivitySourceName);
|
||||
|
||||
// Histogram: policy_run_seconds{mode,tenant,policy}
|
||||
private static readonly Histogram<double> PolicyRunSecondsHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_run_seconds",
|
||||
unit: "s",
|
||||
description: "Duration of policy evaluation runs.");
|
||||
|
||||
// Gauge: policy_run_queue_depth{tenant}
|
||||
private static readonly ObservableGauge<int> PolicyRunQueueDepthGauge =
|
||||
Meter.CreateObservableGauge(
|
||||
"policy_run_queue_depth",
|
||||
observeValue: () => QueueDepthObservations,
|
||||
unit: "jobs",
|
||||
description: "Current depth of pending policy run jobs per tenant.");
|
||||
|
||||
// Counter: policy_rules_fired_total{policy,rule}
|
||||
private static readonly Counter<long> PolicyRulesFiredCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_rules_fired_total",
|
||||
unit: "rules",
|
||||
description: "Total number of policy rules that fired during evaluation.");
|
||||
|
||||
// Counter: policy_vex_overrides_total{policy,vendor}
|
||||
private static readonly Counter<long> PolicyVexOverridesCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_vex_overrides_total",
|
||||
unit: "overrides",
|
||||
description: "Total number of VEX overrides applied during policy evaluation.");
|
||||
|
||||
// Counter: policy_compilation_total{outcome}
|
||||
private static readonly Counter<long> PolicyCompilationCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_compilation_total",
|
||||
unit: "compilations",
|
||||
description: "Total number of policy compilations attempted.");
|
||||
|
||||
// Histogram: policy_compilation_seconds
|
||||
private static readonly Histogram<double> PolicyCompilationSecondsHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_compilation_seconds",
|
||||
unit: "s",
|
||||
description: "Duration of policy compilation.");
|
||||
|
||||
// Counter: policy_simulation_total{tenant,outcome}
|
||||
private static readonly Counter<long> PolicySimulationCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_simulation_total",
|
||||
unit: "simulations",
|
||||
description: "Total number of policy simulations executed.");
|
||||
|
||||
#region Golden Signals - Latency
|
||||
|
||||
// Histogram: policy_api_latency_seconds{endpoint,method,status}
|
||||
private static readonly Histogram<double> ApiLatencyHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_api_latency_seconds",
|
||||
unit: "s",
|
||||
description: "API request latency by endpoint.");
|
||||
|
||||
// Histogram: policy_evaluation_latency_seconds{tenant,policy}
|
||||
private static readonly Histogram<double> EvaluationLatencyHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_evaluation_latency_seconds",
|
||||
unit: "s",
|
||||
description: "Policy evaluation latency per batch.");
|
||||
|
||||
#endregion
|
||||
|
||||
#region Golden Signals - Traffic
|
||||
|
||||
// Counter: policy_requests_total{endpoint,method}
|
||||
private static readonly Counter<long> RequestsCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_requests_total",
|
||||
unit: "requests",
|
||||
description: "Total API requests by endpoint and method.");
|
||||
|
||||
// Counter: policy_evaluations_total{tenant,policy,mode}
|
||||
private static readonly Counter<long> EvaluationsCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_evaluations_total",
|
||||
unit: "evaluations",
|
||||
description: "Total policy evaluations by tenant, policy, and mode.");
|
||||
|
||||
// Counter: policy_findings_materialized_total{tenant,policy}
|
||||
private static readonly Counter<long> FindingsMaterializedCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_findings_materialized_total",
|
||||
unit: "findings",
|
||||
description: "Total findings materialized during policy evaluation.");
|
||||
|
||||
#endregion
|
||||
|
||||
#region Golden Signals - Errors
|
||||
|
||||
// Counter: policy_errors_total{type,tenant}
|
||||
private static readonly Counter<long> ErrorsCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_errors_total",
|
||||
unit: "errors",
|
||||
description: "Total errors by type (compilation, evaluation, api, storage).");
|
||||
|
||||
// Counter: policy_api_errors_total{endpoint,status_code}
|
||||
private static readonly Counter<long> ApiErrorsCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_api_errors_total",
|
||||
unit: "errors",
|
||||
description: "Total API errors by endpoint and status code.");
|
||||
|
||||
// Counter: policy_evaluation_failures_total{tenant,policy,reason}
|
||||
private static readonly Counter<long> EvaluationFailuresCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_evaluation_failures_total",
|
||||
unit: "failures",
|
||||
description: "Total evaluation failures by reason (timeout, determinism, storage, canceled).");
|
||||
|
||||
#endregion
|
||||
|
||||
#region Golden Signals - Saturation
|
||||
|
||||
// Gauge: policy_concurrent_evaluations{tenant}
|
||||
private static readonly ObservableGauge<int> ConcurrentEvaluationsGauge =
|
||||
Meter.CreateObservableGauge(
|
||||
"policy_concurrent_evaluations",
|
||||
observeValue: () => ConcurrentEvaluationsObservations,
|
||||
unit: "evaluations",
|
||||
description: "Current number of concurrent policy evaluations.");
|
||||
|
||||
// Gauge: policy_worker_utilization
|
||||
private static readonly ObservableGauge<double> WorkerUtilizationGauge =
|
||||
Meter.CreateObservableGauge(
|
||||
"policy_worker_utilization",
|
||||
observeValue: () => WorkerUtilizationObservations,
|
||||
unit: "ratio",
|
||||
description: "Worker pool utilization ratio (0.0 to 1.0).");
|
||||
|
||||
#endregion
|
||||
|
||||
#region SLO Metrics
|
||||
|
||||
// Gauge: policy_slo_burn_rate{slo_name}
|
||||
private static readonly ObservableGauge<double> SloBurnRateGauge =
|
||||
Meter.CreateObservableGauge(
|
||||
"policy_slo_burn_rate",
|
||||
observeValue: () => SloBurnRateObservations,
|
||||
unit: "ratio",
|
||||
description: "SLO burn rate over configured window.");
|
||||
|
||||
// Gauge: policy_error_budget_remaining{slo_name}
|
||||
private static readonly ObservableGauge<double> ErrorBudgetRemainingGauge =
|
||||
Meter.CreateObservableGauge(
|
||||
"policy_error_budget_remaining",
|
||||
observeValue: () => ErrorBudgetObservations,
|
||||
unit: "ratio",
|
||||
description: "Remaining error budget as ratio (0.0 to 1.0).");
|
||||
|
||||
// Counter: policy_slo_violations_total{slo_name}
|
||||
private static readonly Counter<long> SloViolationsCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_slo_violations_total",
|
||||
unit: "violations",
|
||||
description: "Total SLO violations detected.");
|
||||
|
||||
#endregion
|
||||
|
||||
#region Risk Scoring Metrics
|
||||
|
||||
// Counter: policy_risk_scoring_jobs_created_total
|
||||
private static readonly Counter<long> RiskScoringJobsCreatedCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_risk_scoring_jobs_created_total",
|
||||
unit: "jobs",
|
||||
description: "Total risk scoring jobs created.");
|
||||
|
||||
// Counter: policy_risk_scoring_triggers_skipped_total
|
||||
private static readonly Counter<long> RiskScoringTriggersSkippedCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_risk_scoring_triggers_skipped_total",
|
||||
unit: "triggers",
|
||||
description: "Total risk scoring triggers skipped due to deduplication.");
|
||||
|
||||
// Histogram: policy_risk_scoring_duration_seconds
|
||||
private static readonly Histogram<double> RiskScoringDurationHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_risk_scoring_duration_seconds",
|
||||
unit: "s",
|
||||
description: "Duration of risk scoring job execution.");
|
||||
|
||||
// Counter: policy_risk_scoring_findings_scored_total
|
||||
private static readonly Counter<long> RiskScoringFindingsScoredCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_risk_scoring_findings_scored_total",
|
||||
unit: "findings",
|
||||
description: "Total findings scored by risk scoring jobs.");
|
||||
|
||||
/// <summary>
|
||||
/// Counter for risk scoring jobs created.
|
||||
/// </summary>
|
||||
public static Counter<long> RiskScoringJobsCreated => RiskScoringJobsCreatedCounter;
|
||||
|
||||
/// <summary>
|
||||
/// Counter for risk scoring triggers skipped.
|
||||
/// </summary>
|
||||
public static Counter<long> RiskScoringTriggersSkipped => RiskScoringTriggersSkippedCounter;
|
||||
|
||||
/// <summary>
|
||||
/// Records risk scoring duration.
|
||||
/// </summary>
|
||||
/// <param name="seconds">Duration in seconds.</param>
|
||||
/// <param name="profileId">Profile identifier.</param>
|
||||
/// <param name="findingCount">Number of findings scored.</param>
|
||||
public static void RecordRiskScoringDuration(double seconds, string profileId, int findingCount)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "profile_id", NormalizeTag(profileId) },
|
||||
{ "finding_count", findingCount.ToString() },
|
||||
};
|
||||
|
||||
RiskScoringDurationHistogram.Record(seconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records findings scored by risk scoring.
|
||||
/// </summary>
|
||||
/// <param name="profileId">Profile identifier.</param>
|
||||
/// <param name="count">Number of findings scored.</param>
|
||||
public static void RecordFindingsScored(string profileId, long count)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "profile_id", NormalizeTag(profileId) },
|
||||
};
|
||||
|
||||
RiskScoringFindingsScoredCounter.Add(count, tags);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
// Storage for observable gauge observations
|
||||
private static IEnumerable<Measurement<int>> QueueDepthObservations = Enumerable.Empty<Measurement<int>>();
|
||||
private static IEnumerable<Measurement<int>> ConcurrentEvaluationsObservations = Enumerable.Empty<Measurement<int>>();
|
||||
private static IEnumerable<Measurement<double>> WorkerUtilizationObservations = Enumerable.Empty<Measurement<double>>();
|
||||
private static IEnumerable<Measurement<double>> SloBurnRateObservations = Enumerable.Empty<Measurement<double>>();
|
||||
private static IEnumerable<Measurement<double>> ErrorBudgetObservations = Enumerable.Empty<Measurement<double>>();
|
||||
|
||||
/// <summary>
|
||||
/// Registers a callback to observe queue depth measurements.
|
||||
/// </summary>
|
||||
/// <param name="observeFunc">Function that returns current queue depth measurements.</param>
|
||||
public static void RegisterQueueDepthObservation(Func<IEnumerable<Measurement<int>>> observeFunc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observeFunc);
|
||||
QueueDepthObservations = observeFunc();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the duration of a policy run.
|
||||
/// </summary>
|
||||
/// <param name="seconds">Duration in seconds.</param>
|
||||
/// <param name="mode">Run mode (full, incremental, simulate).</param>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="policy">Policy identifier.</param>
|
||||
/// <param name="outcome">Outcome of the run (success, failure, canceled).</param>
|
||||
public static void RecordRunDuration(double seconds, string mode, string tenant, string policy, string outcome)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "mode", NormalizeTag(mode) },
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "policy", NormalizeTag(policy) },
|
||||
{ "outcome", NormalizeTag(outcome) },
|
||||
};
|
||||
|
||||
PolicyRunSecondsHistogram.Record(seconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that a policy rule fired during evaluation.
|
||||
/// </summary>
|
||||
/// <param name="policy">Policy identifier.</param>
|
||||
/// <param name="rule">Rule identifier.</param>
|
||||
/// <param name="count">Number of times the rule fired.</param>
|
||||
public static void RecordRuleFired(string policy, string rule, long count = 1)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "policy", NormalizeTag(policy) },
|
||||
{ "rule", NormalizeTag(rule) },
|
||||
};
|
||||
|
||||
PolicyRulesFiredCounter.Add(count, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a VEX override applied during policy evaluation.
|
||||
/// </summary>
|
||||
/// <param name="policy">Policy identifier.</param>
|
||||
/// <param name="vendor">VEX vendor identifier.</param>
|
||||
/// <param name="count">Number of overrides.</param>
|
||||
public static void RecordVexOverride(string policy, string vendor, long count = 1)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "policy", NormalizeTag(policy) },
|
||||
{ "vendor", NormalizeTag(vendor) },
|
||||
};
|
||||
|
||||
PolicyVexOverridesCounter.Add(count, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a policy compilation attempt.
|
||||
/// </summary>
|
||||
/// <param name="outcome">Outcome (success, failure).</param>
|
||||
/// <param name="seconds">Duration in seconds.</param>
|
||||
public static void RecordCompilation(string outcome, double seconds)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "outcome", NormalizeTag(outcome) },
|
||||
};
|
||||
|
||||
PolicyCompilationCounter.Add(1, tags);
|
||||
PolicyCompilationSecondsHistogram.Record(seconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a policy simulation execution.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="outcome">Outcome (success, failure).</param>
|
||||
public static void RecordSimulation(string tenant, string outcome)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "outcome", NormalizeTag(outcome) },
|
||||
};
|
||||
|
||||
PolicySimulationCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
#region Golden Signals - Recording Methods
|
||||
|
||||
/// <summary>
|
||||
/// Records API request latency.
|
||||
/// </summary>
|
||||
/// <param name="seconds">Latency in seconds.</param>
|
||||
/// <param name="endpoint">API endpoint name.</param>
|
||||
/// <param name="method">HTTP method.</param>
|
||||
/// <param name="statusCode">HTTP status code.</param>
|
||||
public static void RecordApiLatency(double seconds, string endpoint, string method, int statusCode)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "endpoint", NormalizeTag(endpoint) },
|
||||
{ "method", NormalizeTag(method) },
|
||||
{ "status", statusCode.ToString() },
|
||||
};
|
||||
|
||||
ApiLatencyHistogram.Record(seconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records policy evaluation latency for a batch.
|
||||
/// </summary>
|
||||
/// <param name="seconds">Latency in seconds.</param>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="policy">Policy identifier.</param>
|
||||
public static void RecordEvaluationLatency(double seconds, string tenant, string policy)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "policy", NormalizeTag(policy) },
|
||||
};
|
||||
|
||||
EvaluationLatencyHistogram.Record(seconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an API request.
|
||||
/// </summary>
|
||||
/// <param name="endpoint">API endpoint name.</param>
|
||||
/// <param name="method">HTTP method.</param>
|
||||
public static void RecordRequest(string endpoint, string method)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "endpoint", NormalizeTag(endpoint) },
|
||||
{ "method", NormalizeTag(method) },
|
||||
};
|
||||
|
||||
RequestsCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a policy evaluation execution.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="policy">Policy identifier.</param>
|
||||
/// <param name="mode">Evaluation mode (full, incremental, simulate).</param>
|
||||
public static void RecordEvaluation(string tenant, string policy, string mode)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "policy", NormalizeTag(policy) },
|
||||
{ "mode", NormalizeTag(mode) },
|
||||
};
|
||||
|
||||
EvaluationsCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records findings materialized during policy evaluation.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="policy">Policy identifier.</param>
|
||||
/// <param name="count">Number of findings materialized.</param>
|
||||
public static void RecordFindingsMaterialized(string tenant, string policy, long count)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "policy", NormalizeTag(policy) },
|
||||
};
|
||||
|
||||
FindingsMaterializedCounter.Add(count, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an error.
|
||||
/// </summary>
|
||||
/// <param name="errorType">Error type (compilation, evaluation, api, storage).</param>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
public static void RecordError(string errorType, string? tenant = null)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "type", NormalizeTag(errorType) },
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
};
|
||||
|
||||
ErrorsCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an API error.
|
||||
/// </summary>
|
||||
/// <param name="endpoint">API endpoint name.</param>
|
||||
/// <param name="statusCode">HTTP status code.</param>
|
||||
public static void RecordApiError(string endpoint, int statusCode)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "endpoint", NormalizeTag(endpoint) },
|
||||
{ "status_code", statusCode.ToString() },
|
||||
};
|
||||
|
||||
ApiErrorsCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an evaluation failure.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="policy">Policy identifier.</param>
|
||||
/// <param name="reason">Failure reason (timeout, determinism, storage, canceled).</param>
|
||||
public static void RecordEvaluationFailure(string tenant, string policy, string reason)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "policy", NormalizeTag(policy) },
|
||||
{ "reason", NormalizeTag(reason) },
|
||||
};
|
||||
|
||||
EvaluationFailuresCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an SLO violation.
|
||||
/// </summary>
|
||||
/// <param name="sloName">Name of the SLO that was violated.</param>
|
||||
public static void RecordSloViolation(string sloName)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "slo_name", NormalizeTag(sloName) },
|
||||
};
|
||||
|
||||
SloViolationsCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a callback to observe concurrent evaluations measurements.
|
||||
/// </summary>
|
||||
/// <param name="observeFunc">Function that returns current concurrent evaluations measurements.</param>
|
||||
public static void RegisterConcurrentEvaluationsObservation(Func<IEnumerable<Measurement<int>>> observeFunc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observeFunc);
|
||||
ConcurrentEvaluationsObservations = observeFunc();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a callback to observe worker utilization measurements.
|
||||
/// </summary>
|
||||
/// <param name="observeFunc">Function that returns current worker utilization measurements.</param>
|
||||
public static void RegisterWorkerUtilizationObservation(Func<IEnumerable<Measurement<double>>> observeFunc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observeFunc);
|
||||
WorkerUtilizationObservations = observeFunc();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a callback to observe SLO burn rate measurements.
|
||||
/// </summary>
|
||||
/// <param name="observeFunc">Function that returns current SLO burn rate measurements.</param>
|
||||
public static void RegisterSloBurnRateObservation(Func<IEnumerable<Measurement<double>>> observeFunc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observeFunc);
|
||||
SloBurnRateObservations = observeFunc();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a callback to observe error budget measurements.
|
||||
/// </summary>
|
||||
/// <param name="observeFunc">Function that returns current error budget measurements.</param>
|
||||
public static void RegisterErrorBudgetObservation(Func<IEnumerable<Measurement<double>>> observeFunc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observeFunc);
|
||||
ErrorBudgetObservations = observeFunc();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for selection layer operations.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="policyId">Policy identifier.</param>
|
||||
/// <returns>The started activity, or null if not sampled.</returns>
|
||||
public static Activity? StartSelectActivity(string? tenant, string? policyId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy.select", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant", NormalizeTenant(tenant));
|
||||
activity?.SetTag("policy.id", policyId ?? "unknown");
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for policy evaluation.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="policyId">Policy identifier.</param>
|
||||
/// <param name="runId">Run identifier.</param>
|
||||
/// <returns>The started activity, or null if not sampled.</returns>
|
||||
public static Activity? StartEvaluateActivity(string? tenant, string? policyId, string? runId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy.evaluate", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant", NormalizeTenant(tenant));
|
||||
activity?.SetTag("policy.id", policyId ?? "unknown");
|
||||
activity?.SetTag("run.id", runId ?? "unknown");
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for materialization operations.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="policyId">Policy identifier.</param>
|
||||
/// <param name="batchSize">Number of items in the batch.</param>
|
||||
/// <returns>The started activity, or null if not sampled.</returns>
|
||||
public static Activity? StartMaterializeActivity(string? tenant, string? policyId, int batchSize)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy.materialize", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant", NormalizeTenant(tenant));
|
||||
activity?.SetTag("policy.id", policyId ?? "unknown");
|
||||
activity?.SetTag("batch.size", batchSize);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for simulation operations.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="policyId">Policy identifier.</param>
|
||||
/// <returns>The started activity, or null if not sampled.</returns>
|
||||
public static Activity? StartSimulateActivity(string? tenant, string? policyId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy.simulate", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant", NormalizeTenant(tenant));
|
||||
activity?.SetTag("policy.id", policyId ?? "unknown");
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for compilation operations.
|
||||
/// </summary>
|
||||
/// <param name="policyId">Policy identifier.</param>
|
||||
/// <param name="version">Policy version.</param>
|
||||
/// <returns>The started activity, or null if not sampled.</returns>
|
||||
public static Activity? StartCompileActivity(string? policyId, string? version)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy.compile", ActivityKind.Internal);
|
||||
activity?.SetTag("policy.id", policyId ?? "unknown");
|
||||
activity?.SetTag("policy.version", version ?? "unknown");
|
||||
return activity;
|
||||
}
|
||||
|
||||
private static string NormalizeTenant(string? tenant)
|
||||
=> string.IsNullOrWhiteSpace(tenant) ? "default" : tenant;
|
||||
|
||||
private static string NormalizeTag(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? "unknown" : value;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for Policy Engine telemetry.
|
||||
/// </summary>
|
||||
public sealed class PolicyEngineTelemetryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether telemetry is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether tracing is enabled.
|
||||
/// </summary>
|
||||
public bool EnableTracing { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether metrics collection is enabled.
|
||||
/// </summary>
|
||||
public bool EnableMetrics { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether structured logging is enabled.
|
||||
/// </summary>
|
||||
public bool EnableLogging { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the service name used in telemetry data.
|
||||
/// </summary>
|
||||
public string? ServiceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OTLP exporter endpoint.
|
||||
/// </summary>
|
||||
public string? OtlpEndpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OTLP exporter headers.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> OtlpHeaders { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional resource attributes for OpenTelemetry.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> ResourceAttributes { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to export telemetry to console.
|
||||
/// </summary>
|
||||
public bool ExportConsole { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum log level for structured logging.
|
||||
/// </summary>
|
||||
public string MinimumLogLevel { get; set; } = "Information";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether incident mode is enabled.
|
||||
/// When enabled, 100% sampling is applied and extended retention windows are used.
|
||||
/// </summary>
|
||||
public bool IncidentMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the sampling ratio for traces (0.0 to 1.0).
|
||||
/// Ignored when <see cref="IncidentMode"/> is enabled.
|
||||
/// </summary>
|
||||
public double TraceSamplingRatio { get; set; } = 0.1;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the telemetry options.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(OtlpEndpoint) && !Uri.TryCreate(OtlpEndpoint, UriKind.Absolute, out _))
|
||||
{
|
||||
throw new InvalidOperationException("Telemetry OTLP endpoint must be a valid absolute URI.");
|
||||
}
|
||||
|
||||
if (TraceSamplingRatio is < 0 or > 1)
|
||||
{
|
||||
throw new InvalidOperationException("Telemetry trace sampling ratio must be between 0.0 and 1.0.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// in-toto statement types for policy evaluation attestations.
|
||||
/// </summary>
|
||||
public static class PolicyAttestationTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// Attestation type for policy evaluation results.
|
||||
/// </summary>
|
||||
public const string PolicyEvaluationV1 = "https://stella-ops.org/attestation/policy-evaluation/v1";
|
||||
|
||||
/// <summary>
|
||||
/// DSSE payload type for in-toto statements.
|
||||
/// </summary>
|
||||
public const string InTotoPayloadType = "application/vnd.in-toto+json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// in-toto Statement structure for policy evaluation attestations.
|
||||
/// </summary>
|
||||
public sealed class PolicyEvaluationStatement
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public string Type { get; init; } = "https://in-toto.io/Statement/v1";
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public List<InTotoSubject> Subject { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public string PredicateType { get; init; } = PolicyAttestationTypes.PolicyEvaluationV1;
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public required PolicyEvaluationPredicate Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject reference in an in-toto statement.
|
||||
/// </summary>
|
||||
public sealed class InTotoSubject
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required Dictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicate containing policy evaluation details.
|
||||
/// </summary>
|
||||
public sealed class PolicyEvaluationPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Run identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("runId")]
|
||||
public required string RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenant")]
|
||||
public required string Tenant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyId")]
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyVersion")]
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation mode (full, incremental, simulate).
|
||||
/// </summary>
|
||||
[JsonPropertyName("mode")]
|
||||
public required string Mode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when evaluation started.
|
||||
/// </summary>
|
||||
[JsonPropertyName("startedAt")]
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when evaluation completed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("completedAt")]
|
||||
public required DateTimeOffset CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("outcome")]
|
||||
public required string Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Determinism hash for reproducibility verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("determinismHash")]
|
||||
public string? DeterminismHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the evidence bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceBundle")]
|
||||
public EvidenceBundleRef? EvidenceBundle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary metrics from the evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metrics")]
|
||||
public required PolicyEvaluationMetrics Metrics { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("environment")]
|
||||
public required PolicyEvaluationEnvironment Environment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an evidence bundle.
|
||||
/// </summary>
|
||||
public sealed class EvidenceBundleRef
|
||||
{
|
||||
[JsonPropertyName("bundleId")]
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
[JsonPropertyName("contentHash")]
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
public string? Uri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metrics from the policy evaluation.
|
||||
/// </summary>
|
||||
public sealed class PolicyEvaluationMetrics
|
||||
{
|
||||
[JsonPropertyName("totalFindings")]
|
||||
public int TotalFindings { get; init; }
|
||||
|
||||
[JsonPropertyName("rulesEvaluated")]
|
||||
public int RulesEvaluated { get; init; }
|
||||
|
||||
[JsonPropertyName("rulesFired")]
|
||||
public int RulesFired { get; init; }
|
||||
|
||||
[JsonPropertyName("vexOverridesApplied")]
|
||||
public int VexOverridesApplied { get; init; }
|
||||
|
||||
[JsonPropertyName("durationSeconds")]
|
||||
public double DurationSeconds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Environment information for the evaluation.
|
||||
/// </summary>
|
||||
public sealed class PolicyEvaluationEnvironment
|
||||
{
|
||||
[JsonPropertyName("serviceVersion")]
|
||||
public required string ServiceVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("hostId")]
|
||||
public string? HostId { get; init; }
|
||||
|
||||
[JsonPropertyName("sealedMode")]
|
||||
public bool SealedMode { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating DSSE attestations for policy evaluations.
|
||||
/// </summary>
|
||||
public sealed class PolicyEvaluationAttestationService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PolicyEvaluationAttestationService(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an in-toto statement for a policy evaluation.
|
||||
/// </summary>
|
||||
public PolicyEvaluationStatement CreateStatement(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string policyVersion,
|
||||
string mode,
|
||||
DateTimeOffset startedAt,
|
||||
string outcome,
|
||||
string serviceVersion,
|
||||
int totalFindings,
|
||||
int rulesEvaluated,
|
||||
int rulesFired,
|
||||
int vexOverridesApplied,
|
||||
double durationSeconds,
|
||||
string? determinismHash = null,
|
||||
EvidenceBundle? evidenceBundle = null,
|
||||
bool sealedMode = false,
|
||||
IEnumerable<(string name, string digestAlgorithm, string digestValue)>? subjects = null)
|
||||
{
|
||||
var statement = new PolicyEvaluationStatement
|
||||
{
|
||||
Predicate = new PolicyEvaluationPredicate
|
||||
{
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
PolicyVersion = policyVersion,
|
||||
Mode = mode,
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
Outcome = outcome,
|
||||
DeterminismHash = determinismHash,
|
||||
EvidenceBundle = evidenceBundle != null
|
||||
? new EvidenceBundleRef
|
||||
{
|
||||
BundleId = evidenceBundle.BundleId,
|
||||
ContentHash = evidenceBundle.ContentHash ?? "unknown",
|
||||
}
|
||||
: null,
|
||||
Metrics = new PolicyEvaluationMetrics
|
||||
{
|
||||
TotalFindings = totalFindings,
|
||||
RulesEvaluated = rulesEvaluated,
|
||||
RulesFired = rulesFired,
|
||||
VexOverridesApplied = vexOverridesApplied,
|
||||
DurationSeconds = durationSeconds,
|
||||
},
|
||||
Environment = new PolicyEvaluationEnvironment
|
||||
{
|
||||
ServiceVersion = serviceVersion,
|
||||
HostId = Environment.MachineName,
|
||||
SealedMode = sealedMode,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Add subjects if provided
|
||||
if (subjects != null)
|
||||
{
|
||||
foreach (var (name, algorithm, value) in subjects)
|
||||
{
|
||||
statement.Subject.Add(new InTotoSubject
|
||||
{
|
||||
Name = name,
|
||||
Digest = new Dictionary<string, string> { [algorithm] = value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add the policy as a subject
|
||||
statement.Subject.Add(new InTotoSubject
|
||||
{
|
||||
Name = $"policy://{tenant}/{policyId}@{policyVersion}",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = ComputePolicyDigest(policyId, policyVersion),
|
||||
},
|
||||
});
|
||||
|
||||
return statement;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes an in-toto statement to JSON bytes for signing.
|
||||
/// </summary>
|
||||
public byte[] SerializeStatement(PolicyEvaluationStatement statement)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(statement);
|
||||
var json = JsonSerializer.Serialize(statement, PolicyAttestationJsonContext.Default.PolicyEvaluationStatement);
|
||||
return Encoding.UTF8.GetBytes(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an unsigned DSSE envelope for the statement.
|
||||
/// This envelope can be sent to the Attestor service for signing.
|
||||
/// </summary>
|
||||
public DsseEnvelopeRequest CreateEnvelopeRequest(PolicyEvaluationStatement statement)
|
||||
{
|
||||
var payload = SerializeStatement(statement);
|
||||
|
||||
return new DsseEnvelopeRequest
|
||||
{
|
||||
PayloadType = PolicyAttestationTypes.InTotoPayloadType,
|
||||
Payload = payload,
|
||||
PayloadBase64 = Convert.ToBase64String(payload),
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputePolicyDigest(string policyId, string policyVersion)
|
||||
{
|
||||
var input = $"{policyId}@{policyVersion}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a DSSE envelope (to be sent to Attestor service).
|
||||
/// </summary>
|
||||
public sealed class DsseEnvelopeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// DSSE payload type.
|
||||
/// </summary>
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw payload bytes.
|
||||
/// </summary>
|
||||
public required byte[] Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded payload for transmission.
|
||||
/// </summary>
|
||||
public required string PayloadBase64 { get; init; }
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(PolicyEvaluationStatement))]
|
||||
[JsonSerializable(typeof(PolicyEvaluationPredicate))]
|
||||
[JsonSerializable(typeof(InTotoSubject))]
|
||||
[JsonSerializable(typeof(EvidenceBundleRef))]
|
||||
[JsonSerializable(typeof(PolicyEvaluationMetrics))]
|
||||
[JsonSerializable(typeof(PolicyEvaluationEnvironment))]
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
internal partial class PolicyAttestationJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Provides structured timeline events for policy evaluation and decision flows.
|
||||
/// Events are emitted as structured logs with correlation to traces.
|
||||
/// </summary>
|
||||
public sealed class PolicyTimelineEvents
|
||||
{
|
||||
private readonly ILogger<PolicyTimelineEvents> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PolicyTimelineEvents(ILogger<PolicyTimelineEvents> logger, TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
#region Evaluation Flow Events
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a policy evaluation run starts.
|
||||
/// </summary>
|
||||
public void EmitRunStarted(string runId, string tenant, string policyId, string policyVersion, string mode)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.RunStarted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
PolicyVersion = policyVersion,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["mode"] = mode,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a policy evaluation run completes.
|
||||
/// </summary>
|
||||
public void EmitRunCompleted(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string outcome,
|
||||
double durationSeconds,
|
||||
int findingsCount,
|
||||
string? determinismHash = null)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.RunCompleted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["outcome"] = outcome,
|
||||
["duration_seconds"] = durationSeconds,
|
||||
["findings_count"] = findingsCount,
|
||||
["determinism_hash"] = determinismHash,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a batch selection phase starts.
|
||||
/// </summary>
|
||||
public void EmitSelectionStarted(string runId, string tenant, string policyId, int batchNumber)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.SelectionStarted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["batch_number"] = batchNumber,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a batch selection phase completes.
|
||||
/// </summary>
|
||||
public void EmitSelectionCompleted(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
int batchNumber,
|
||||
int tupleCount,
|
||||
double durationSeconds)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.SelectionCompleted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["batch_number"] = batchNumber,
|
||||
["tuple_count"] = tupleCount,
|
||||
["duration_seconds"] = durationSeconds,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when batch evaluation starts.
|
||||
/// </summary>
|
||||
public void EmitEvaluationStarted(string runId, string tenant, string policyId, int batchNumber, int tupleCount)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.EvaluationStarted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["batch_number"] = batchNumber,
|
||||
["tuple_count"] = tupleCount,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when batch evaluation completes.
|
||||
/// </summary>
|
||||
public void EmitEvaluationCompleted(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
int batchNumber,
|
||||
int rulesEvaluated,
|
||||
int rulesFired,
|
||||
double durationSeconds)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.EvaluationCompleted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["batch_number"] = batchNumber,
|
||||
["rules_evaluated"] = rulesEvaluated,
|
||||
["rules_fired"] = rulesFired,
|
||||
["duration_seconds"] = durationSeconds,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Decision Flow Events
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a rule matches during evaluation.
|
||||
/// </summary>
|
||||
public void EmitRuleMatched(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string ruleId,
|
||||
string findingKey,
|
||||
string? severity = null)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.RuleMatched,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["rule_id"] = ruleId,
|
||||
["finding_key"] = findingKey,
|
||||
["severity"] = severity,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a VEX override is applied.
|
||||
/// </summary>
|
||||
public void EmitVexOverrideApplied(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string findingKey,
|
||||
string vendor,
|
||||
string status,
|
||||
string? justification = null)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.VexOverrideApplied,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["finding_key"] = findingKey,
|
||||
["vendor"] = vendor,
|
||||
["status"] = status,
|
||||
["justification"] = justification,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a final verdict is determined for a finding.
|
||||
/// </summary>
|
||||
public void EmitVerdictDetermined(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string findingKey,
|
||||
string verdict,
|
||||
string severity,
|
||||
string? reachabilityState = null,
|
||||
IReadOnlyList<string>? contributingRules = null)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.VerdictDetermined,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["finding_key"] = findingKey,
|
||||
["verdict"] = verdict,
|
||||
["severity"] = severity,
|
||||
["reachability_state"] = reachabilityState,
|
||||
["contributing_rules"] = contributingRules,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when materialization of findings starts.
|
||||
/// </summary>
|
||||
public void EmitMaterializationStarted(string runId, string tenant, string policyId, int findingsCount)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.MaterializationStarted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["findings_count"] = findingsCount,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when materialization of findings completes.
|
||||
/// </summary>
|
||||
public void EmitMaterializationCompleted(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
int findingsWritten,
|
||||
int findingsUpdated,
|
||||
double durationSeconds)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.MaterializationCompleted,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["findings_written"] = findingsWritten,
|
||||
["findings_updated"] = findingsUpdated,
|
||||
["duration_seconds"] = durationSeconds,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Events
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when an error occurs during evaluation.
|
||||
/// </summary>
|
||||
public void EmitError(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string errorCode,
|
||||
string errorMessage,
|
||||
string? phase = null)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.Error,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["error_code"] = errorCode,
|
||||
["error_message"] = errorMessage,
|
||||
["phase"] = phase,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt, LogLevel.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an event when a determinism violation is detected.
|
||||
/// </summary>
|
||||
public void EmitDeterminismViolation(
|
||||
string runId,
|
||||
string tenant,
|
||||
string policyId,
|
||||
string violationType,
|
||||
string details)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventType = TimelineEventType.DeterminismViolation,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
RunId = runId,
|
||||
Tenant = tenant,
|
||||
PolicyId = policyId,
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
Data = new Dictionary<string, object?>
|
||||
{
|
||||
["violation_type"] = violationType,
|
||||
["details"] = details,
|
||||
},
|
||||
};
|
||||
|
||||
LogTimelineEvent(evt, LogLevel.Warning);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private void LogTimelineEvent(TimelineEvent evt, LogLevel level = LogLevel.Information)
|
||||
{
|
||||
_logger.Log(
|
||||
level,
|
||||
"PolicyTimeline: {EventType} | run={RunId} tenant={Tenant} policy={PolicyId} trace={TraceId} span={SpanId} data={Data}",
|
||||
evt.EventType,
|
||||
evt.RunId,
|
||||
evt.Tenant,
|
||||
evt.PolicyId,
|
||||
evt.TraceId,
|
||||
evt.SpanId,
|
||||
JsonSerializer.Serialize(evt.Data, TimelineEventJsonContext.Default.DictionaryStringObject));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of timeline events emitted during policy evaluation.
|
||||
/// </summary>
|
||||
public enum TimelineEventType
|
||||
{
|
||||
RunStarted,
|
||||
RunCompleted,
|
||||
SelectionStarted,
|
||||
SelectionCompleted,
|
||||
EvaluationStarted,
|
||||
EvaluationCompleted,
|
||||
RuleMatched,
|
||||
VexOverrideApplied,
|
||||
VerdictDetermined,
|
||||
MaterializationStarted,
|
||||
MaterializationCompleted,
|
||||
Error,
|
||||
DeterminismViolation,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a timeline event for policy evaluation flows.
|
||||
/// </summary>
|
||||
public sealed record TimelineEvent
|
||||
{
|
||||
public required TimelineEventType EventType { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required string RunId { get; init; }
|
||||
public required string Tenant { get; init; }
|
||||
public required string PolicyId { get; init; }
|
||||
public string? PolicyVersion { get; init; }
|
||||
public string? TraceId { get; init; }
|
||||
public string? SpanId { get; init; }
|
||||
public Dictionary<string, object?>? Data { get; init; }
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(Dictionary<string, object?>))]
|
||||
[JsonSourceGenerationOptions(WriteIndented = false)]
|
||||
internal partial class TimelineEventJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring Policy Engine telemetry.
|
||||
/// </summary>
|
||||
public static class TelemetryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures Policy Engine telemetry including metrics, traces, and structured logging.
|
||||
/// </summary>
|
||||
/// <param name="builder">The web application builder.</param>
|
||||
/// <param name="options">Policy engine options containing telemetry configuration.</param>
|
||||
public static void ConfigurePolicyEngineTelemetry(this WebApplicationBuilder builder, PolicyEngineOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var telemetry = options.Telemetry ?? new PolicyEngineTelemetryOptions();
|
||||
|
||||
if (telemetry.EnableLogging)
|
||||
{
|
||||
builder.Host.UseSerilog((context, services, configuration) =>
|
||||
{
|
||||
ConfigureSerilog(configuration, telemetry, builder.Environment.EnvironmentName, builder.Environment.ApplicationName);
|
||||
});
|
||||
}
|
||||
|
||||
if (!telemetry.Enabled || (!telemetry.EnableTracing && !telemetry.EnableMetrics))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var openTelemetry = builder.Services.AddOpenTelemetry();
|
||||
|
||||
openTelemetry.ConfigureResource(resource =>
|
||||
{
|
||||
var serviceName = telemetry.ServiceName ?? builder.Environment.ApplicationName;
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
|
||||
|
||||
resource.AddService(serviceName, serviceVersion: version, serviceInstanceId: Environment.MachineName);
|
||||
resource.AddAttributes(new[]
|
||||
{
|
||||
new KeyValuePair<string, object>("deployment.environment", builder.Environment.EnvironmentName),
|
||||
});
|
||||
|
||||
foreach (var attribute in telemetry.ResourceAttributes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attribute.Key) || attribute.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
resource.AddAttributes(new[] { new KeyValuePair<string, object>(attribute.Key, attribute.Value) });
|
||||
}
|
||||
});
|
||||
|
||||
if (telemetry.EnableTracing)
|
||||
{
|
||||
openTelemetry.WithTracing(tracing =>
|
||||
{
|
||||
tracing
|
||||
.AddSource(PolicyEngineTelemetry.ActivitySourceName)
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation();
|
||||
|
||||
ConfigureTracingExporter(telemetry, tracing);
|
||||
});
|
||||
}
|
||||
|
||||
if (telemetry.EnableMetrics)
|
||||
{
|
||||
openTelemetry.WithMetrics(metrics =>
|
||||
{
|
||||
metrics
|
||||
.AddMeter(PolicyEngineTelemetry.MeterName)
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation();
|
||||
|
||||
ConfigureMetricsExporter(telemetry, metrics);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureSerilog(
|
||||
LoggerConfiguration configuration,
|
||||
PolicyEngineTelemetryOptions telemetry,
|
||||
string environmentName,
|
||||
string applicationName)
|
||||
{
|
||||
if (!Enum.TryParse(telemetry.MinimumLogLevel, ignoreCase: true, out LogEventLevel level))
|
||||
{
|
||||
level = LogEventLevel.Information;
|
||||
}
|
||||
|
||||
configuration
|
||||
.MinimumLevel.Is(level)
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.With<PolicyEngineActivityEnricher>()
|
||||
.Enrich.WithProperty("service.name", telemetry.ServiceName ?? applicationName)
|
||||
.Enrich.WithProperty("deployment.environment", environmentName)
|
||||
.WriteTo.Console(outputTemplate: "[{Timestamp:O}] [{Level:u3}] {Message:lj} {Properties}{NewLine}{Exception}");
|
||||
}
|
||||
|
||||
private static void ConfigureTracingExporter(PolicyEngineTelemetryOptions telemetry, TracerProviderBuilder tracing)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
|
||||
{
|
||||
if (telemetry.ExportConsole)
|
||||
{
|
||||
tracing.AddConsoleExporter();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
tracing.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = new Uri(telemetry.OtlpEndpoint);
|
||||
var headers = BuildHeaders(telemetry);
|
||||
if (!string.IsNullOrEmpty(headers))
|
||||
{
|
||||
options.Headers = headers;
|
||||
}
|
||||
});
|
||||
|
||||
if (telemetry.ExportConsole)
|
||||
{
|
||||
tracing.AddConsoleExporter();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureMetricsExporter(PolicyEngineTelemetryOptions telemetry, MeterProviderBuilder metrics)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
|
||||
{
|
||||
if (telemetry.ExportConsole)
|
||||
{
|
||||
metrics.AddConsoleExporter();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
metrics.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = new Uri(telemetry.OtlpEndpoint);
|
||||
var headers = BuildHeaders(telemetry);
|
||||
if (!string.IsNullOrEmpty(headers))
|
||||
{
|
||||
options.Headers = headers;
|
||||
}
|
||||
});
|
||||
|
||||
if (telemetry.ExportConsole)
|
||||
{
|
||||
metrics.AddConsoleExporter();
|
||||
}
|
||||
}
|
||||
|
||||
private static string? BuildHeaders(PolicyEngineTelemetryOptions telemetry)
|
||||
{
|
||||
if (telemetry.OtlpHeaders.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.Join(",", telemetry.OtlpHeaders
|
||||
.Where(static kvp => !string.IsNullOrWhiteSpace(kvp.Key) && !string.IsNullOrWhiteSpace(kvp.Value))
|
||||
.Select(static kvp => $"{kvp.Key}={kvp.Value}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serilog enricher that adds activity context (trace_id, span_id) to log events.
|
||||
/// </summary>
|
||||
internal sealed class PolicyEngineActivityEnricher : ILogEventEnricher
|
||||
{
|
||||
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
|
||||
{
|
||||
var activity = Activity.Current;
|
||||
if (activity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (activity.TraceId != default)
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("trace_id", activity.TraceId.ToString()));
|
||||
}
|
||||
|
||||
if (activity.SpanId != default)
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("span_id", activity.SpanId.ToString()));
|
||||
}
|
||||
|
||||
if (activity.ParentSpanId != default)
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("parent_span_id", activity.ParentSpanId.ToString()));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(activity.TraceStateString))
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("trace_state", activity.TraceStateString));
|
||||
}
|
||||
|
||||
// Add Policy Engine specific context if available
|
||||
var policyId = activity.GetTagItem("policy.id")?.ToString();
|
||||
if (!string.IsNullOrEmpty(policyId))
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("policy_id", policyId));
|
||||
}
|
||||
|
||||
var runId = activity.GetTagItem("run.id")?.ToString();
|
||||
if (!string.IsNullOrEmpty(runId))
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("run_id", runId));
|
||||
}
|
||||
|
||||
var tenant = activity.GetTagItem("tenant")?.ToString();
|
||||
if (!string.IsNullOrEmpty(tenant))
|
||||
{
|
||||
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("tenant", tenant));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Hosting;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Workers;
|
||||
|
||||
@@ -12,15 +13,18 @@ internal sealed class PolicyEngineBootstrapWorker : BackgroundService
|
||||
private readonly ILogger<PolicyEngineBootstrapWorker> logger;
|
||||
private readonly PolicyEngineStartupDiagnostics diagnostics;
|
||||
private readonly PolicyEngineOptions options;
|
||||
private readonly RiskProfileConfigurationService riskProfileService;
|
||||
|
||||
public PolicyEngineBootstrapWorker(
|
||||
ILogger<PolicyEngineBootstrapWorker> logger,
|
||||
PolicyEngineStartupDiagnostics diagnostics,
|
||||
PolicyEngineOptions options)
|
||||
PolicyEngineOptions options,
|
||||
RiskProfileConfigurationService riskProfileService)
|
||||
{
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.riskProfileService = riskProfileService ?? throw new ArgumentNullException(nameof(riskProfileService));
|
||||
}
|
||||
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -29,6 +33,19 @@ internal sealed class PolicyEngineBootstrapWorker : BackgroundService
|
||||
options.Authority.Issuer,
|
||||
options.Storage.DatabaseName);
|
||||
|
||||
if (options.RiskProfile.Enabled)
|
||||
{
|
||||
riskProfileService.LoadProfiles();
|
||||
logger.LogInformation(
|
||||
"Risk profile integration enabled. Default profile: {DefaultProfileId}. Loaded profiles: {ProfileCount}.",
|
||||
riskProfileService.DefaultProfileId,
|
||||
riskProfileService.GetProfileIds().Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Risk profile integration is disabled.");
|
||||
}
|
||||
|
||||
diagnostics.MarkReady();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Hashing;
|
||||
|
||||
/// <summary>
|
||||
/// Service for computing deterministic hashes of risk profiles.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileHasher
|
||||
{
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase),
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic SHA-256 hash of the risk profile.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to hash.</param>
|
||||
/// <returns>Lowercase hex-encoded SHA-256 hash.</returns>
|
||||
public string ComputeHash(RiskProfileModel profile)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
var canonical = CreateCanonicalForm(profile);
|
||||
var json = JsonSerializer.Serialize(canonical, CanonicalJsonOptions);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic content hash that ignores identity fields (id, version).
|
||||
/// Useful for detecting semantic changes regardless of versioning.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to hash.</param>
|
||||
/// <returns>Lowercase hex-encoded SHA-256 hash.</returns>
|
||||
public string ComputeContentHash(RiskProfileModel profile)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
var canonical = CreateCanonicalContentForm(profile);
|
||||
var json = JsonSerializer.Serialize(canonical, CanonicalJsonOptions);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that two profiles have the same semantic content (ignoring identity fields).
|
||||
/// </summary>
|
||||
public bool AreEquivalent(RiskProfileModel profile1, RiskProfileModel profile2)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile1);
|
||||
ArgumentNullException.ThrowIfNull(profile2);
|
||||
|
||||
return ComputeContentHash(profile1) == ComputeContentHash(profile2);
|
||||
}
|
||||
|
||||
private static CanonicalRiskProfile CreateCanonicalForm(RiskProfileModel profile)
|
||||
{
|
||||
return new CanonicalRiskProfile
|
||||
{
|
||||
Id = profile.Id,
|
||||
Version = profile.Version,
|
||||
Description = profile.Description,
|
||||
Extends = profile.Extends,
|
||||
Signals = CreateCanonicalSignals(profile.Signals),
|
||||
Weights = CreateCanonicalWeights(profile.Weights),
|
||||
Overrides = CreateCanonicalOverrides(profile.Overrides),
|
||||
Metadata = CreateCanonicalMetadata(profile.Metadata),
|
||||
};
|
||||
}
|
||||
|
||||
private static CanonicalRiskProfileContent CreateCanonicalContentForm(RiskProfileModel profile)
|
||||
{
|
||||
return new CanonicalRiskProfileContent
|
||||
{
|
||||
Signals = CreateCanonicalSignals(profile.Signals),
|
||||
Weights = CreateCanonicalWeights(profile.Weights),
|
||||
Overrides = CreateCanonicalOverrides(profile.Overrides),
|
||||
};
|
||||
}
|
||||
|
||||
private static List<CanonicalSignal> CreateCanonicalSignals(List<RiskSignal> signals)
|
||||
{
|
||||
return signals
|
||||
.OrderBy(s => s.Name, StringComparer.Ordinal)
|
||||
.Select(s => new CanonicalSignal
|
||||
{
|
||||
Name = s.Name,
|
||||
Source = s.Source,
|
||||
Type = s.Type.ToString().ToLowerInvariant(),
|
||||
Path = s.Path,
|
||||
Transform = s.Transform,
|
||||
Unit = s.Unit,
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static SortedDictionary<string, double> CreateCanonicalWeights(Dictionary<string, double> weights)
|
||||
{
|
||||
return new SortedDictionary<string, double>(weights, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static CanonicalOverrides CreateCanonicalOverrides(RiskOverrides overrides)
|
||||
{
|
||||
return new CanonicalOverrides
|
||||
{
|
||||
Severity = overrides.Severity
|
||||
.Select(CreateCanonicalSeverityOverride)
|
||||
.ToList(),
|
||||
Decisions = overrides.Decisions
|
||||
.Select(CreateCanonicalDecisionOverride)
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
private static CanonicalSeverityOverride CreateCanonicalSeverityOverride(SeverityOverride rule)
|
||||
{
|
||||
return new CanonicalSeverityOverride
|
||||
{
|
||||
When = CreateCanonicalWhen(rule.When),
|
||||
Set = rule.Set.ToString().ToLowerInvariant(),
|
||||
};
|
||||
}
|
||||
|
||||
private static CanonicalDecisionOverride CreateCanonicalDecisionOverride(DecisionOverride rule)
|
||||
{
|
||||
return new CanonicalDecisionOverride
|
||||
{
|
||||
When = CreateCanonicalWhen(rule.When),
|
||||
Action = rule.Action.ToString().ToLowerInvariant(),
|
||||
Reason = rule.Reason,
|
||||
};
|
||||
}
|
||||
|
||||
private static SortedDictionary<string, object> CreateCanonicalWhen(Dictionary<string, object> when)
|
||||
{
|
||||
return new SortedDictionary<string, object>(when, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static SortedDictionary<string, object?>? CreateCanonicalMetadata(Dictionary<string, object?>? metadata)
|
||||
{
|
||||
if (metadata == null || metadata.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SortedDictionary<string, object?>(metadata, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
#region Canonical Form Types
|
||||
|
||||
private sealed class CanonicalRiskProfile
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? Extends { get; init; }
|
||||
public required List<CanonicalSignal> Signals { get; init; }
|
||||
public required SortedDictionary<string, double> Weights { get; init; }
|
||||
public required CanonicalOverrides Overrides { get; init; }
|
||||
public SortedDictionary<string, object?>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
private sealed class CanonicalRiskProfileContent
|
||||
{
|
||||
public required List<CanonicalSignal> Signals { get; init; }
|
||||
public required SortedDictionary<string, double> Weights { get; init; }
|
||||
public required CanonicalOverrides Overrides { get; init; }
|
||||
}
|
||||
|
||||
private sealed class CanonicalSignal
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Source { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public string? Path { get; init; }
|
||||
public string? Transform { get; init; }
|
||||
public string? Unit { get; init; }
|
||||
}
|
||||
|
||||
private sealed class CanonicalOverrides
|
||||
{
|
||||
public required List<CanonicalSeverityOverride> Severity { get; init; }
|
||||
public required List<CanonicalDecisionOverride> Decisions { get; init; }
|
||||
}
|
||||
|
||||
private sealed class CanonicalSeverityOverride
|
||||
{
|
||||
public required SortedDictionary<string, object> When { get; init; }
|
||||
public required string Set { get; init; }
|
||||
}
|
||||
|
||||
private sealed class CanonicalDecisionOverride
|
||||
{
|
||||
public required SortedDictionary<string, object> When { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle status of a risk profile.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskProfileLifecycleStatus>))]
|
||||
public enum RiskProfileLifecycleStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Profile is in draft/development.
|
||||
/// </summary>
|
||||
[JsonPropertyName("draft")]
|
||||
Draft,
|
||||
|
||||
/// <summary>
|
||||
/// Profile is active and available for use.
|
||||
/// </summary>
|
||||
[JsonPropertyName("active")]
|
||||
Active,
|
||||
|
||||
/// <summary>
|
||||
/// Profile is deprecated; use is discouraged.
|
||||
/// </summary>
|
||||
[JsonPropertyName("deprecated")]
|
||||
Deprecated,
|
||||
|
||||
/// <summary>
|
||||
/// Profile is archived; no longer available for new use.
|
||||
/// </summary>
|
||||
[JsonPropertyName("archived")]
|
||||
Archived
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about a profile version.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileVersionInfo(
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("status")] RiskProfileLifecycleStatus Status,
|
||||
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("created_by")] string? CreatedBy,
|
||||
[property: JsonPropertyName("activated_at")] DateTimeOffset? ActivatedAt,
|
||||
[property: JsonPropertyName("deprecated_at")] DateTimeOffset? DeprecatedAt,
|
||||
[property: JsonPropertyName("archived_at")] DateTimeOffset? ArchivedAt,
|
||||
[property: JsonPropertyName("content_hash")] string ContentHash,
|
||||
[property: JsonPropertyName("successor_version")] string? SuccessorVersion = null,
|
||||
[property: JsonPropertyName("deprecation_reason")] string? DeprecationReason = null);
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a profile lifecycle changes.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileLifecycleEvent(
|
||||
[property: JsonPropertyName("event_id")] string EventId,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("event_type")] RiskProfileLifecycleEventType EventType,
|
||||
[property: JsonPropertyName("old_status")] RiskProfileLifecycleStatus? OldStatus,
|
||||
[property: JsonPropertyName("new_status")] RiskProfileLifecycleStatus NewStatus,
|
||||
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
|
||||
[property: JsonPropertyName("actor")] string? Actor,
|
||||
[property: JsonPropertyName("reason")] string? Reason = null);
|
||||
|
||||
/// <summary>
|
||||
/// Types of lifecycle events.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskProfileLifecycleEventType>))]
|
||||
public enum RiskProfileLifecycleEventType
|
||||
{
|
||||
[JsonPropertyName("created")]
|
||||
Created,
|
||||
|
||||
[JsonPropertyName("activated")]
|
||||
Activated,
|
||||
|
||||
[JsonPropertyName("deprecated")]
|
||||
Deprecated,
|
||||
|
||||
[JsonPropertyName("archived")]
|
||||
Archived,
|
||||
|
||||
[JsonPropertyName("restored")]
|
||||
Restored
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a version comparison.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileVersionComparison(
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("from_version")] string FromVersion,
|
||||
[property: JsonPropertyName("to_version")] string ToVersion,
|
||||
[property: JsonPropertyName("has_breaking_changes")] bool HasBreakingChanges,
|
||||
[property: JsonPropertyName("changes")] IReadOnlyList<RiskProfileChange> Changes);
|
||||
|
||||
/// <summary>
|
||||
/// A specific change between profile versions.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileChange(
|
||||
[property: JsonPropertyName("change_type")] RiskProfileChangeType ChangeType,
|
||||
[property: JsonPropertyName("path")] string Path,
|
||||
[property: JsonPropertyName("description")] string Description,
|
||||
[property: JsonPropertyName("is_breaking")] bool IsBreaking);
|
||||
|
||||
/// <summary>
|
||||
/// Types of changes between profile versions.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskProfileChangeType>))]
|
||||
public enum RiskProfileChangeType
|
||||
{
|
||||
[JsonPropertyName("signal_added")]
|
||||
SignalAdded,
|
||||
|
||||
[JsonPropertyName("signal_removed")]
|
||||
SignalRemoved,
|
||||
|
||||
[JsonPropertyName("signal_modified")]
|
||||
SignalModified,
|
||||
|
||||
[JsonPropertyName("weight_changed")]
|
||||
WeightChanged,
|
||||
|
||||
[JsonPropertyName("override_added")]
|
||||
OverrideAdded,
|
||||
|
||||
[JsonPropertyName("override_removed")]
|
||||
OverrideRemoved,
|
||||
|
||||
[JsonPropertyName("override_modified")]
|
||||
OverrideModified,
|
||||
|
||||
[JsonPropertyName("metadata_changed")]
|
||||
MetadataChanged,
|
||||
|
||||
[JsonPropertyName("inheritance_changed")]
|
||||
InheritanceChanged
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Policy.RiskProfile.Hashing;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing risk profile lifecycle and versioning.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileLifecycleService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly RiskProfileHasher _hasher;
|
||||
private readonly ConcurrentDictionary<string, List<RiskProfileVersionInfo>> _versions;
|
||||
private readonly ConcurrentDictionary<string, List<RiskProfileLifecycleEvent>> _events;
|
||||
|
||||
public RiskProfileLifecycleService(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_hasher = new RiskProfileHasher();
|
||||
_versions = new ConcurrentDictionary<string, List<RiskProfileVersionInfo>>(StringComparer.OrdinalIgnoreCase);
|
||||
_events = new ConcurrentDictionary<string, List<RiskProfileLifecycleEvent>>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new profile version in draft status.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to create.</param>
|
||||
/// <param name="createdBy">Creator identifier.</param>
|
||||
/// <returns>Version info for the created profile.</returns>
|
||||
public RiskProfileVersionInfo CreateVersion(RiskProfileModel profile, string? createdBy = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var contentHash = _hasher.ComputeContentHash(profile);
|
||||
|
||||
var versionInfo = new RiskProfileVersionInfo(
|
||||
ProfileId: profile.Id,
|
||||
Version: profile.Version,
|
||||
Status: RiskProfileLifecycleStatus.Draft,
|
||||
CreatedAt: now,
|
||||
CreatedBy: createdBy,
|
||||
ActivatedAt: null,
|
||||
DeprecatedAt: null,
|
||||
ArchivedAt: null,
|
||||
ContentHash: contentHash);
|
||||
|
||||
var versions = _versions.GetOrAdd(profile.Id, _ => new List<RiskProfileVersionInfo>());
|
||||
lock (versions)
|
||||
{
|
||||
if (versions.Any(v => v.Version == profile.Version))
|
||||
{
|
||||
throw new InvalidOperationException($"Version {profile.Version} already exists for profile {profile.Id}.");
|
||||
}
|
||||
versions.Add(versionInfo);
|
||||
}
|
||||
|
||||
RecordEvent(profile.Id, profile.Version, RiskProfileLifecycleEventType.Created, null, RiskProfileLifecycleStatus.Draft, createdBy);
|
||||
|
||||
return versionInfo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates a profile version, making it available for use.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="version">The version to activate.</param>
|
||||
/// <param name="actor">Actor performing the activation.</param>
|
||||
/// <returns>Updated version info.</returns>
|
||||
public RiskProfileVersionInfo Activate(string profileId, string version, string? actor = null)
|
||||
{
|
||||
var info = GetVersionInfo(profileId, version);
|
||||
if (info == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Version {version} not found for profile {profileId}.");
|
||||
}
|
||||
|
||||
if (info.Status != RiskProfileLifecycleStatus.Draft)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot activate profile in {info.Status} status. Only Draft profiles can be activated.");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = info with
|
||||
{
|
||||
Status = RiskProfileLifecycleStatus.Active,
|
||||
ActivatedAt = now
|
||||
};
|
||||
|
||||
UpdateVersionInfo(profileId, version, updated);
|
||||
RecordEvent(profileId, version, RiskProfileLifecycleEventType.Activated, info.Status, RiskProfileLifecycleStatus.Active, actor);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deprecates a profile version.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="version">The version to deprecate.</param>
|
||||
/// <param name="successorVersion">Optional successor version to recommend.</param>
|
||||
/// <param name="reason">Reason for deprecation.</param>
|
||||
/// <param name="actor">Actor performing the deprecation.</param>
|
||||
/// <returns>Updated version info.</returns>
|
||||
public RiskProfileVersionInfo Deprecate(
|
||||
string profileId,
|
||||
string version,
|
||||
string? successorVersion = null,
|
||||
string? reason = null,
|
||||
string? actor = null)
|
||||
{
|
||||
var info = GetVersionInfo(profileId, version);
|
||||
if (info == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Version {version} not found for profile {profileId}.");
|
||||
}
|
||||
|
||||
if (info.Status != RiskProfileLifecycleStatus.Active)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot deprecate profile in {info.Status} status. Only Active profiles can be deprecated.");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = info with
|
||||
{
|
||||
Status = RiskProfileLifecycleStatus.Deprecated,
|
||||
DeprecatedAt = now,
|
||||
SuccessorVersion = successorVersion,
|
||||
DeprecationReason = reason
|
||||
};
|
||||
|
||||
UpdateVersionInfo(profileId, version, updated);
|
||||
RecordEvent(profileId, version, RiskProfileLifecycleEventType.Deprecated, info.Status, RiskProfileLifecycleStatus.Deprecated, actor, reason);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Archives a profile version, removing it from active use.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="version">The version to archive.</param>
|
||||
/// <param name="actor">Actor performing the archive.</param>
|
||||
/// <returns>Updated version info.</returns>
|
||||
public RiskProfileVersionInfo Archive(string profileId, string version, string? actor = null)
|
||||
{
|
||||
var info = GetVersionInfo(profileId, version);
|
||||
if (info == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Version {version} not found for profile {profileId}.");
|
||||
}
|
||||
|
||||
if (info.Status == RiskProfileLifecycleStatus.Archived)
|
||||
{
|
||||
return info;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = info with
|
||||
{
|
||||
Status = RiskProfileLifecycleStatus.Archived,
|
||||
ArchivedAt = now
|
||||
};
|
||||
|
||||
UpdateVersionInfo(profileId, version, updated);
|
||||
RecordEvent(profileId, version, RiskProfileLifecycleEventType.Archived, info.Status, RiskProfileLifecycleStatus.Archived, actor);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores an archived profile to deprecated status.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="version">The version to restore.</param>
|
||||
/// <param name="actor">Actor performing the restoration.</param>
|
||||
/// <returns>Updated version info.</returns>
|
||||
public RiskProfileVersionInfo Restore(string profileId, string version, string? actor = null)
|
||||
{
|
||||
var info = GetVersionInfo(profileId, version);
|
||||
if (info == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Version {version} not found for profile {profileId}.");
|
||||
}
|
||||
|
||||
if (info.Status != RiskProfileLifecycleStatus.Archived)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot restore profile in {info.Status} status. Only Archived profiles can be restored.");
|
||||
}
|
||||
|
||||
var updated = info with
|
||||
{
|
||||
Status = RiskProfileLifecycleStatus.Deprecated,
|
||||
ArchivedAt = null
|
||||
};
|
||||
|
||||
UpdateVersionInfo(profileId, version, updated);
|
||||
RecordEvent(profileId, version, RiskProfileLifecycleEventType.Restored, info.Status, RiskProfileLifecycleStatus.Deprecated, actor);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets version info for a specific profile version.
|
||||
/// </summary>
|
||||
public RiskProfileVersionInfo? GetVersionInfo(string profileId, string version)
|
||||
{
|
||||
if (_versions.TryGetValue(profileId, out var versions))
|
||||
{
|
||||
lock (versions)
|
||||
{
|
||||
return versions.FirstOrDefault(v => v.Version == version);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all versions for a profile.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RiskProfileVersionInfo> GetAllVersions(string profileId)
|
||||
{
|
||||
if (_versions.TryGetValue(profileId, out var versions))
|
||||
{
|
||||
lock (versions)
|
||||
{
|
||||
return versions.OrderByDescending(v => ParseVersion(v.Version)).ToList().AsReadOnly();
|
||||
}
|
||||
}
|
||||
|
||||
return Array.Empty<RiskProfileVersionInfo>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest active version for a profile.
|
||||
/// </summary>
|
||||
public RiskProfileVersionInfo? GetLatestActive(string profileId)
|
||||
{
|
||||
var versions = GetAllVersions(profileId);
|
||||
return versions.FirstOrDefault(v => v.Status == RiskProfileLifecycleStatus.Active);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets lifecycle events for a profile.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RiskProfileLifecycleEvent> GetEvents(string profileId, int limit = 100)
|
||||
{
|
||||
if (_events.TryGetValue(profileId, out var events))
|
||||
{
|
||||
lock (events)
|
||||
{
|
||||
return events.OrderByDescending(e => e.Timestamp).Take(limit).ToList().AsReadOnly();
|
||||
}
|
||||
}
|
||||
|
||||
return Array.Empty<RiskProfileLifecycleEvent>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two profile versions and returns the differences.
|
||||
/// </summary>
|
||||
public RiskProfileVersionComparison CompareVersions(
|
||||
RiskProfileModel fromProfile,
|
||||
RiskProfileModel toProfile)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fromProfile);
|
||||
ArgumentNullException.ThrowIfNull(toProfile);
|
||||
|
||||
if (fromProfile.Id != toProfile.Id)
|
||||
{
|
||||
throw new ArgumentException("Profiles must have the same ID to compare.");
|
||||
}
|
||||
|
||||
var changes = new List<RiskProfileChange>();
|
||||
var hasBreaking = false;
|
||||
|
||||
CompareSignals(fromProfile, toProfile, changes, ref hasBreaking);
|
||||
CompareWeights(fromProfile, toProfile, changes);
|
||||
CompareOverrides(fromProfile, toProfile, changes);
|
||||
CompareInheritance(fromProfile, toProfile, changes, ref hasBreaking);
|
||||
CompareMetadata(fromProfile, toProfile, changes);
|
||||
|
||||
return new RiskProfileVersionComparison(
|
||||
ProfileId: fromProfile.Id,
|
||||
FromVersion: fromProfile.Version,
|
||||
ToVersion: toProfile.Version,
|
||||
HasBreakingChanges: hasBreaking,
|
||||
Changes: changes.AsReadOnly());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if an upgrade from one version to another is safe (non-breaking).
|
||||
/// </summary>
|
||||
public bool IsSafeUpgrade(RiskProfileModel fromProfile, RiskProfileModel toProfile)
|
||||
{
|
||||
var comparison = CompareVersions(fromProfile, toProfile);
|
||||
return !comparison.HasBreakingChanges;
|
||||
}
|
||||
|
||||
private void UpdateVersionInfo(string profileId, string version, RiskProfileVersionInfo updated)
|
||||
{
|
||||
if (_versions.TryGetValue(profileId, out var versions))
|
||||
{
|
||||
lock (versions)
|
||||
{
|
||||
var index = versions.FindIndex(v => v.Version == version);
|
||||
if (index >= 0)
|
||||
{
|
||||
versions[index] = updated;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordEvent(
|
||||
string profileId,
|
||||
string version,
|
||||
RiskProfileLifecycleEventType eventType,
|
||||
RiskProfileLifecycleStatus? oldStatus,
|
||||
RiskProfileLifecycleStatus newStatus,
|
||||
string? actor,
|
||||
string? reason = null)
|
||||
{
|
||||
var eventId = GenerateEventId();
|
||||
var evt = new RiskProfileLifecycleEvent(
|
||||
EventId: eventId,
|
||||
ProfileId: profileId,
|
||||
Version: version,
|
||||
EventType: eventType,
|
||||
OldStatus: oldStatus,
|
||||
NewStatus: newStatus,
|
||||
Timestamp: _timeProvider.GetUtcNow(),
|
||||
Actor: actor,
|
||||
Reason: reason);
|
||||
|
||||
var events = _events.GetOrAdd(profileId, _ => new List<RiskProfileLifecycleEvent>());
|
||||
lock (events)
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareSignals(
|
||||
RiskProfileModel from,
|
||||
RiskProfileModel to,
|
||||
List<RiskProfileChange> changes,
|
||||
ref bool hasBreaking)
|
||||
{
|
||||
var fromSignals = from.Signals.ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase);
|
||||
var toSignals = to.Signals.ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var (name, signal) in fromSignals)
|
||||
{
|
||||
if (!toSignals.ContainsKey(name))
|
||||
{
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.SignalRemoved,
|
||||
$"/signals/{name}",
|
||||
$"Signal '{name}' was removed",
|
||||
IsBreaking: true));
|
||||
hasBreaking = true;
|
||||
}
|
||||
else if (!SignalsEqual(signal, toSignals[name]))
|
||||
{
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.SignalModified,
|
||||
$"/signals/{name}",
|
||||
$"Signal '{name}' was modified",
|
||||
IsBreaking: false));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var name in toSignals.Keys)
|
||||
{
|
||||
if (!fromSignals.ContainsKey(name))
|
||||
{
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.SignalAdded,
|
||||
$"/signals/{name}",
|
||||
$"Signal '{name}' was added",
|
||||
IsBreaking: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareWeights(
|
||||
RiskProfileModel from,
|
||||
RiskProfileModel to,
|
||||
List<RiskProfileChange> changes)
|
||||
{
|
||||
var allKeys = from.Weights.Keys.Union(to.Weights.Keys, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var key in allKeys)
|
||||
{
|
||||
var fromHas = from.Weights.TryGetValue(key, out var fromWeight);
|
||||
var toHas = to.Weights.TryGetValue(key, out var toWeight);
|
||||
|
||||
if (fromHas && toHas && Math.Abs(fromWeight - toWeight) > 0.001)
|
||||
{
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.WeightChanged,
|
||||
$"/weights/{key}",
|
||||
$"Weight for '{key}' changed from {fromWeight:F3} to {toWeight:F3}",
|
||||
IsBreaking: false));
|
||||
}
|
||||
else if (fromHas && !toHas)
|
||||
{
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.WeightChanged,
|
||||
$"/weights/{key}",
|
||||
$"Weight for '{key}' was removed",
|
||||
IsBreaking: false));
|
||||
}
|
||||
else if (!fromHas && toHas)
|
||||
{
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.WeightChanged,
|
||||
$"/weights/{key}",
|
||||
$"Weight for '{key}' was added with value {toWeight:F3}",
|
||||
IsBreaking: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareOverrides(
|
||||
RiskProfileModel from,
|
||||
RiskProfileModel to,
|
||||
List<RiskProfileChange> changes)
|
||||
{
|
||||
if (from.Overrides.Severity.Count != to.Overrides.Severity.Count)
|
||||
{
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.OverrideModified,
|
||||
"/overrides/severity",
|
||||
$"Severity overrides changed from {from.Overrides.Severity.Count} to {to.Overrides.Severity.Count} rules",
|
||||
IsBreaking: false));
|
||||
}
|
||||
|
||||
if (from.Overrides.Decisions.Count != to.Overrides.Decisions.Count)
|
||||
{
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.OverrideModified,
|
||||
"/overrides/decisions",
|
||||
$"Decision overrides changed from {from.Overrides.Decisions.Count} to {to.Overrides.Decisions.Count} rules",
|
||||
IsBreaking: false));
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareInheritance(
|
||||
RiskProfileModel from,
|
||||
RiskProfileModel to,
|
||||
List<RiskProfileChange> changes,
|
||||
ref bool hasBreaking)
|
||||
{
|
||||
if (!string.Equals(from.Extends, to.Extends, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var fromExtends = from.Extends ?? "(none)";
|
||||
var toExtends = to.Extends ?? "(none)";
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.InheritanceChanged,
|
||||
"/extends",
|
||||
$"Inheritance changed from '{fromExtends}' to '{toExtends}'",
|
||||
IsBreaking: true));
|
||||
hasBreaking = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareMetadata(
|
||||
RiskProfileModel from,
|
||||
RiskProfileModel to,
|
||||
List<RiskProfileChange> changes)
|
||||
{
|
||||
var fromKeys = from.Metadata?.Keys ?? Enumerable.Empty<string>();
|
||||
var toKeys = to.Metadata?.Keys ?? Enumerable.Empty<string>();
|
||||
var allKeys = fromKeys.Union(toKeys, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var key in allKeys)
|
||||
{
|
||||
var fromHas = from.Metadata?.TryGetValue(key, out var fromValue) ?? false;
|
||||
var toHas = to.Metadata?.TryGetValue(key, out var toValue) ?? false;
|
||||
|
||||
if (fromHas != toHas || (fromHas && toHas && !Equals(fromValue, toValue)))
|
||||
{
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.MetadataChanged,
|
||||
$"/metadata/{key}",
|
||||
$"Metadata key '{key}' was changed",
|
||||
IsBreaking: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SignalsEqual(RiskSignal a, RiskSignal b)
|
||||
{
|
||||
return a.Source == b.Source &&
|
||||
a.Type == b.Type &&
|
||||
a.Path == b.Path &&
|
||||
a.Transform == b.Transform &&
|
||||
a.Unit == b.Unit;
|
||||
}
|
||||
|
||||
private static Version ParseVersion(string version)
|
||||
{
|
||||
var parts = version.Split(['-', '+'], 2);
|
||||
if (Version.TryParse(parts[0], out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
return new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
private static string GenerateEventId()
|
||||
{
|
||||
var guid = Guid.NewGuid().ToByteArray();
|
||||
return $"rple-{Convert.ToHexStringLower(guid)[..16]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Merge;
|
||||
|
||||
/// <summary>
|
||||
/// Service for merging and resolving inheritance in risk profiles.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileMergeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a risk profile by applying inheritance from parent profiles.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to resolve.</param>
|
||||
/// <param name="profileResolver">Function to resolve parent profiles by ID.</param>
|
||||
/// <param name="maxDepth">Maximum inheritance depth to prevent cycles.</param>
|
||||
/// <returns>A fully resolved profile with inherited values merged.</returns>
|
||||
public RiskProfileModel ResolveInheritance(
|
||||
RiskProfileModel profile,
|
||||
Func<string, RiskProfileModel?> profileResolver,
|
||||
int maxDepth = 10)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
ArgumentNullException.ThrowIfNull(profileResolver);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(profile.Extends))
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
|
||||
var chain = BuildInheritanceChain(profile, profileResolver, maxDepth);
|
||||
return MergeChain(chain);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple profiles in order (later profiles override earlier ones).
|
||||
/// </summary>
|
||||
/// <param name="profiles">Profiles to merge, in order of precedence (first = base, last = highest priority).</param>
|
||||
/// <returns>A merged profile.</returns>
|
||||
public RiskProfileModel MergeProfiles(IEnumerable<RiskProfileModel> profiles)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profiles);
|
||||
|
||||
var profileList = profiles.ToList();
|
||||
if (profileList.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one profile is required.", nameof(profiles));
|
||||
}
|
||||
|
||||
return MergeChain(profileList);
|
||||
}
|
||||
|
||||
private List<RiskProfileModel> BuildInheritanceChain(
|
||||
RiskProfileModel profile,
|
||||
Func<string, RiskProfileModel?> resolver,
|
||||
int maxDepth)
|
||||
{
|
||||
var chain = new List<RiskProfileModel>();
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var current = profile;
|
||||
var depth = 0;
|
||||
|
||||
while (current != null && depth < maxDepth)
|
||||
{
|
||||
if (!visited.Add(current.Id))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Circular inheritance detected: profile '{current.Id}' already in chain.");
|
||||
}
|
||||
|
||||
chain.Add(current);
|
||||
depth++;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(current.Extends))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var parent = resolver(current.Extends);
|
||||
if (parent == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Parent profile '{current.Extends}' not found for profile '{current.Id}'.");
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
if (depth >= maxDepth)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Maximum inheritance depth ({maxDepth}) exceeded for profile '{profile.Id}'.");
|
||||
}
|
||||
|
||||
// Reverse so base profiles come first
|
||||
chain.Reverse();
|
||||
return chain;
|
||||
}
|
||||
|
||||
private RiskProfileModel MergeChain(List<RiskProfileModel> chain)
|
||||
{
|
||||
if (chain.Count == 1)
|
||||
{
|
||||
return CloneProfile(chain[0]);
|
||||
}
|
||||
|
||||
var result = CloneProfile(chain[0]);
|
||||
|
||||
for (int i = 1; i < chain.Count; i++)
|
||||
{
|
||||
var overlay = chain[i];
|
||||
MergeInto(result, overlay);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void MergeInto(RiskProfileModel target, RiskProfileModel overlay)
|
||||
{
|
||||
// Override identity fields
|
||||
target.Id = overlay.Id;
|
||||
target.Version = overlay.Version;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(overlay.Description))
|
||||
{
|
||||
target.Description = overlay.Description;
|
||||
}
|
||||
|
||||
// Clear extends since inheritance has been resolved
|
||||
target.Extends = null;
|
||||
|
||||
// Merge signals (overlay signals replace by name, new ones are added)
|
||||
MergeSignals(target.Signals, overlay.Signals);
|
||||
|
||||
// Merge weights (overlay weights override by key)
|
||||
foreach (var kvp in overlay.Weights)
|
||||
{
|
||||
target.Weights[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
// Merge overrides (append overlay rules)
|
||||
MergeOverrides(target.Overrides, overlay.Overrides);
|
||||
|
||||
// Merge metadata (overlay values override by key)
|
||||
if (overlay.Metadata != null)
|
||||
{
|
||||
target.Metadata ??= new Dictionary<string, object?>();
|
||||
foreach (var kvp in overlay.Metadata)
|
||||
{
|
||||
target.Metadata[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void MergeSignals(List<RiskSignal> target, List<RiskSignal> overlay)
|
||||
{
|
||||
var signalsByName = target.ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var signal in overlay)
|
||||
{
|
||||
if (signalsByName.TryGetValue(signal.Name, out var existing))
|
||||
{
|
||||
// Replace existing signal
|
||||
var index = target.IndexOf(existing);
|
||||
target[index] = CloneSignal(signal);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new signal
|
||||
target.Add(CloneSignal(signal));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void MergeOverrides(RiskOverrides target, RiskOverrides overlay)
|
||||
{
|
||||
// Append severity overrides (overlay rules take precedence by being evaluated later)
|
||||
foreach (var rule in overlay.Severity)
|
||||
{
|
||||
target.Severity.Add(CloneSeverityOverride(rule));
|
||||
}
|
||||
|
||||
// Append decision overrides
|
||||
foreach (var rule in overlay.Decisions)
|
||||
{
|
||||
target.Decisions.Add(CloneDecisionOverride(rule));
|
||||
}
|
||||
}
|
||||
|
||||
private static RiskProfileModel CloneProfile(RiskProfileModel source)
|
||||
{
|
||||
return new RiskProfileModel
|
||||
{
|
||||
Id = source.Id,
|
||||
Version = source.Version,
|
||||
Description = source.Description,
|
||||
Extends = source.Extends,
|
||||
Signals = source.Signals.Select(CloneSignal).ToList(),
|
||||
Weights = new Dictionary<string, double>(source.Weights),
|
||||
Overrides = new RiskOverrides
|
||||
{
|
||||
Severity = source.Overrides.Severity.Select(CloneSeverityOverride).ToList(),
|
||||
Decisions = source.Overrides.Decisions.Select(CloneDecisionOverride).ToList(),
|
||||
},
|
||||
Metadata = source.Metadata != null
|
||||
? new Dictionary<string, object?>(source.Metadata)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private static RiskSignal CloneSignal(RiskSignal source)
|
||||
{
|
||||
return new RiskSignal
|
||||
{
|
||||
Name = source.Name,
|
||||
Source = source.Source,
|
||||
Type = source.Type,
|
||||
Path = source.Path,
|
||||
Transform = source.Transform,
|
||||
Unit = source.Unit,
|
||||
};
|
||||
}
|
||||
|
||||
private static SeverityOverride CloneSeverityOverride(SeverityOverride source)
|
||||
{
|
||||
return new SeverityOverride
|
||||
{
|
||||
When = new Dictionary<string, object>(source.When),
|
||||
Set = source.Set,
|
||||
};
|
||||
}
|
||||
|
||||
private static DecisionOverride CloneDecisionOverride(DecisionOverride source)
|
||||
{
|
||||
return new DecisionOverride
|
||||
{
|
||||
When = new Dictionary<string, object>(source.When),
|
||||
Action = source.Action,
|
||||
Reason = source.Reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a risk profile definition used to score and prioritize findings.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Stable identifier for the risk profile (slug or URN).
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SemVer for the profile definition.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary of the profile intent.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional parent profile ID for inheritance.
|
||||
/// </summary>
|
||||
[JsonPropertyName("extends")]
|
||||
public string? Extends { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signal definitions used for risk scoring.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signals")]
|
||||
public List<RiskSignal> Signals { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Weight per signal name; weights are normalized by the consumer.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weights")]
|
||||
public Dictionary<string, double> Weights { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Override rules for severity and decisions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("overrides")]
|
||||
public RiskOverrides Overrides { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Free-form metadata with stable keys.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public Dictionary<string, object?>? Metadata { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A signal definition used in risk scoring.
|
||||
/// </summary>
|
||||
public sealed class RiskSignal
|
||||
{
|
||||
/// <summary>
|
||||
/// Logical signal key (e.g., reachability, kev, exploit_chain).
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Upstream provider or calculation origin.
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public required string Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signal type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required RiskSignalType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON Pointer to the signal in the evidence document.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional transform applied before weighting.
|
||||
/// </summary>
|
||||
[JsonPropertyName("transform")]
|
||||
public string? Transform { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional unit for numeric signals.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unit")]
|
||||
public string? Unit { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signal type enumeration.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskSignalType>))]
|
||||
public enum RiskSignalType
|
||||
{
|
||||
[JsonPropertyName("boolean")]
|
||||
Boolean,
|
||||
|
||||
[JsonPropertyName("numeric")]
|
||||
Numeric,
|
||||
|
||||
[JsonPropertyName("categorical")]
|
||||
Categorical,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override rules for severity and decisions.
|
||||
/// </summary>
|
||||
public sealed class RiskOverrides
|
||||
{
|
||||
/// <summary>
|
||||
/// Severity override rules.
|
||||
/// </summary>
|
||||
[JsonPropertyName("severity")]
|
||||
public List<SeverityOverride> Severity { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Decision override rules.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decisions")]
|
||||
public List<DecisionOverride> Decisions { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A severity override rule.
|
||||
/// </summary>
|
||||
public sealed class SeverityOverride
|
||||
{
|
||||
/// <summary>
|
||||
/// Predicate over signals (key/value equals).
|
||||
/// </summary>
|
||||
[JsonPropertyName("when")]
|
||||
public required Dictionary<string, object> When { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity to set when predicate matches.
|
||||
/// </summary>
|
||||
[JsonPropertyName("set")]
|
||||
public required RiskSeverity Set { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A decision override rule.
|
||||
/// </summary>
|
||||
public sealed class DecisionOverride
|
||||
{
|
||||
/// <summary>
|
||||
/// Predicate over signals (key/value equals).
|
||||
/// </summary>
|
||||
[JsonPropertyName("when")]
|
||||
public required Dictionary<string, object> When { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Action to take when predicate matches.
|
||||
/// </summary>
|
||||
[JsonPropertyName("action")]
|
||||
public required RiskAction Action { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional reason for the override.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity levels.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskSeverity>))]
|
||||
public enum RiskSeverity
|
||||
{
|
||||
[JsonPropertyName("critical")]
|
||||
Critical,
|
||||
|
||||
[JsonPropertyName("high")]
|
||||
High,
|
||||
|
||||
[JsonPropertyName("medium")]
|
||||
Medium,
|
||||
|
||||
[JsonPropertyName("low")]
|
||||
Low,
|
||||
|
||||
[JsonPropertyName("informational")]
|
||||
Informational,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decision actions.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskAction>))]
|
||||
public enum RiskAction
|
||||
{
|
||||
[JsonPropertyName("allow")]
|
||||
Allow,
|
||||
|
||||
[JsonPropertyName("review")]
|
||||
Review,
|
||||
|
||||
[JsonPropertyName("deny")]
|
||||
Deny,
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Json.Schema;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Schema;
|
||||
@@ -6,14 +8,54 @@ namespace StellaOps.Policy.RiskProfile.Schema;
|
||||
public static class RiskProfileSchemaProvider
|
||||
{
|
||||
private const string SchemaResource = "StellaOps.Policy.RiskProfile.Schemas.risk-profile-schema@1.json";
|
||||
private const string SchemaVersion = "1";
|
||||
|
||||
private static string? _cachedSchemaText;
|
||||
private static string? _cachedETag;
|
||||
|
||||
public static JsonSchema GetSchema()
|
||||
{
|
||||
var schemaText = GetSchemaText();
|
||||
return JsonSchema.FromText(schemaText);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the raw JSON schema text.
|
||||
/// </summary>
|
||||
public static string GetSchemaText()
|
||||
{
|
||||
if (_cachedSchemaText is not null)
|
||||
{
|
||||
return _cachedSchemaText;
|
||||
}
|
||||
|
||||
using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(SchemaResource)
|
||||
?? throw new InvalidOperationException($"Schema resource '{SchemaResource}' not found.");
|
||||
using var reader = new StreamReader(stream);
|
||||
var schemaText = reader.ReadToEnd();
|
||||
_cachedSchemaText = reader.ReadToEnd();
|
||||
|
||||
return JsonSchema.FromText(schemaText);
|
||||
return _cachedSchemaText;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the schema version identifier.
|
||||
/// </summary>
|
||||
public static string GetSchemaVersion() => SchemaVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Returns an ETag for the schema content.
|
||||
/// </summary>
|
||||
public static string GetETag()
|
||||
{
|
||||
if (_cachedETag is not null)
|
||||
{
|
||||
return _cachedETag;
|
||||
}
|
||||
|
||||
var schemaText = GetSchemaText();
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(schemaText));
|
||||
_cachedETag = $"\"{Convert.ToHexStringLower(hash)[..16]}\"";
|
||||
|
||||
return _cachedETag;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Json.Schema;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
using StellaOps.Policy.RiskProfile.Schema;
|
||||
using StellaOps.Policy.RiskProfile.Validation;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostics report for a risk profile validation.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileDiagnosticsReport(
|
||||
string ProfileId,
|
||||
string Version,
|
||||
int SignalCount,
|
||||
int WeightCount,
|
||||
int OverrideCount,
|
||||
int ErrorCount,
|
||||
int WarningCount,
|
||||
DateTimeOffset GeneratedAt,
|
||||
ImmutableArray<RiskProfileIssue> Issues,
|
||||
ImmutableArray<string> Recommendations);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a validation issue in a risk profile.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileIssue(
|
||||
string Code,
|
||||
string Message,
|
||||
RiskProfileIssueSeverity Severity,
|
||||
string Path)
|
||||
{
|
||||
public static RiskProfileIssue Error(string code, string message, string path)
|
||||
=> new(code, message, RiskProfileIssueSeverity.Error, path);
|
||||
|
||||
public static RiskProfileIssue Warning(string code, string message, string path)
|
||||
=> new(code, message, RiskProfileIssueSeverity.Warning, path);
|
||||
|
||||
public static RiskProfileIssue Info(string code, string message, string path)
|
||||
=> new(code, message, RiskProfileIssueSeverity.Info, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity levels for risk profile issues.
|
||||
/// </summary>
|
||||
public enum RiskProfileIssueSeverity
|
||||
{
|
||||
Error,
|
||||
Warning,
|
||||
Info
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides validation and diagnostics for risk profiles.
|
||||
/// </summary>
|
||||
public static class RiskProfileDiagnostics
|
||||
{
|
||||
private static readonly RiskProfileValidator Validator = new();
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a diagnostics report for a risk profile.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to validate.</param>
|
||||
/// <param name="timeProvider">Optional time provider.</param>
|
||||
/// <returns>Diagnostics report.</returns>
|
||||
public static RiskProfileDiagnosticsReport Create(RiskProfileModel profile, TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
var time = (timeProvider ?? TimeProvider.System).GetUtcNow();
|
||||
var issues = ImmutableArray.CreateBuilder<RiskProfileIssue>();
|
||||
|
||||
ValidateStructure(profile, issues);
|
||||
ValidateSignals(profile, issues);
|
||||
ValidateWeights(profile, issues);
|
||||
ValidateOverrides(profile, issues);
|
||||
ValidateInheritance(profile, issues);
|
||||
|
||||
var errorCount = issues.Count(static i => i.Severity == RiskProfileIssueSeverity.Error);
|
||||
var warningCount = issues.Count(static i => i.Severity == RiskProfileIssueSeverity.Warning);
|
||||
var recommendations = BuildRecommendations(profile, errorCount, warningCount);
|
||||
var overrideCount = profile.Overrides.Severity.Count + profile.Overrides.Decisions.Count;
|
||||
|
||||
return new RiskProfileDiagnosticsReport(
|
||||
profile.Id,
|
||||
profile.Version,
|
||||
profile.Signals.Count,
|
||||
profile.Weights.Count,
|
||||
overrideCount,
|
||||
errorCount,
|
||||
warningCount,
|
||||
time,
|
||||
issues.ToImmutable(),
|
||||
recommendations);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a risk profile JSON against the schema.
|
||||
/// </summary>
|
||||
/// <param name="json">The JSON to validate.</param>
|
||||
/// <returns>Collection of validation issues.</returns>
|
||||
public static ImmutableArray<RiskProfileIssue> ValidateJson(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return ImmutableArray.Create(
|
||||
RiskProfileIssue.Error("RISK001", "Profile JSON is required.", "/"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var results = Validator.Validate(json);
|
||||
if (results.IsValid)
|
||||
{
|
||||
return ImmutableArray<RiskProfileIssue>.Empty;
|
||||
}
|
||||
|
||||
return ExtractSchemaErrors(results).ToImmutableArray();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return ImmutableArray.Create(
|
||||
RiskProfileIssue.Error("RISK002", $"Invalid JSON: {ex.Message}", "/"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a risk profile model for semantic correctness.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to validate.</param>
|
||||
/// <returns>Collection of validation issues.</returns>
|
||||
public static ImmutableArray<RiskProfileIssue> Validate(RiskProfileModel profile)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
var issues = ImmutableArray.CreateBuilder<RiskProfileIssue>();
|
||||
|
||||
ValidateStructure(profile, issues);
|
||||
ValidateSignals(profile, issues);
|
||||
ValidateWeights(profile, issues);
|
||||
ValidateOverrides(profile, issues);
|
||||
ValidateInheritance(profile, issues);
|
||||
|
||||
return issues.ToImmutable();
|
||||
}
|
||||
|
||||
private static void ValidateStructure(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(profile.Id))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Error("RISK010", "Profile ID is required.", "/id"));
|
||||
}
|
||||
else if (profile.Id.Contains(' '))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Warning("RISK011", "Profile ID should not contain spaces.", "/id"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(profile.Version))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Error("RISK012", "Profile version is required.", "/version"));
|
||||
}
|
||||
else if (!IsValidSemVer(profile.Version))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Warning("RISK013", "Profile version should follow SemVer format.", "/version"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSignals(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
|
||||
{
|
||||
if (profile.Signals.Count == 0)
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Warning("RISK020", "Profile has no signals defined.", "/signals"));
|
||||
return;
|
||||
}
|
||||
|
||||
var signalNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (int i = 0; i < profile.Signals.Count; i++)
|
||||
{
|
||||
var signal = profile.Signals[i];
|
||||
var path = $"/signals/{i}";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signal.Name))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Error("RISK021", $"Signal at index {i} has no name.", path));
|
||||
}
|
||||
else if (!signalNames.Add(signal.Name))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Error("RISK022", $"Duplicate signal name: {signal.Name}", path));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signal.Source))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Error("RISK023", $"Signal '{signal.Name}' has no source.", $"{path}/source"));
|
||||
}
|
||||
|
||||
if (signal.Type == RiskSignalType.Numeric && string.IsNullOrWhiteSpace(signal.Unit))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Info("RISK024", $"Numeric signal '{signal.Name}' has no unit specified.", $"{path}/unit"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateWeights(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
|
||||
{
|
||||
var signalNames = profile.Signals.Select(s => s.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
double totalWeight = 0;
|
||||
|
||||
foreach (var (name, weight) in profile.Weights)
|
||||
{
|
||||
if (!signalNames.Contains(name))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Warning("RISK030", $"Weight defined for unknown signal: {name}", $"/weights/{name}"));
|
||||
}
|
||||
|
||||
if (weight < 0)
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Error("RISK031", $"Weight for '{name}' is negative: {weight}", $"/weights/{name}"));
|
||||
}
|
||||
else if (weight == 0)
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Info("RISK032", $"Weight for '{name}' is zero.", $"/weights/{name}"));
|
||||
}
|
||||
|
||||
totalWeight += weight;
|
||||
}
|
||||
|
||||
foreach (var signal in profile.Signals)
|
||||
{
|
||||
if (!profile.Weights.ContainsKey(signal.Name))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Warning("RISK033", $"Signal '{signal.Name}' has no weight defined.", $"/weights"));
|
||||
}
|
||||
}
|
||||
|
||||
if (totalWeight > 0 && Math.Abs(totalWeight - 1.0) > 0.01)
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Info("RISK034", $"Weights sum to {totalWeight:F3}; consider normalizing to 1.0.", "/weights"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateOverrides(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
|
||||
{
|
||||
for (int i = 0; i < profile.Overrides.Severity.Count; i++)
|
||||
{
|
||||
var rule = profile.Overrides.Severity[i];
|
||||
var path = $"/overrides/severity/{i}";
|
||||
|
||||
if (rule.When.Count == 0)
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Warning("RISK040", $"Severity override at index {i} has empty 'when' clause.", path));
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < profile.Overrides.Decisions.Count; i++)
|
||||
{
|
||||
var rule = profile.Overrides.Decisions[i];
|
||||
var path = $"/overrides/decisions/{i}";
|
||||
|
||||
if (rule.When.Count == 0)
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Warning("RISK041", $"Decision override at index {i} has empty 'when' clause.", path));
|
||||
}
|
||||
|
||||
if (rule.Action == RiskAction.Deny && string.IsNullOrWhiteSpace(rule.Reason))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Warning("RISK042", $"Decision override at index {i} with 'deny' action should have a reason.", path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateInheritance(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(profile.Extends))
|
||||
{
|
||||
if (string.Equals(profile.Extends, profile.Id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Error("RISK050", "Profile cannot extend itself.", "/extends"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> BuildRecommendations(RiskProfileModel profile, int errorCount, int warningCount)
|
||||
{
|
||||
var recommendations = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
if (errorCount > 0)
|
||||
{
|
||||
recommendations.Add("Resolve errors before using this profile in production.");
|
||||
}
|
||||
|
||||
if (warningCount > 0)
|
||||
{
|
||||
recommendations.Add("Review warnings to ensure profile behaves as expected.");
|
||||
}
|
||||
|
||||
if (profile.Signals.Count == 0)
|
||||
{
|
||||
recommendations.Add("Add at least one signal to enable risk scoring.");
|
||||
}
|
||||
|
||||
if (profile.Weights.Count == 0 && profile.Signals.Count > 0)
|
||||
{
|
||||
recommendations.Add("Define weights for signals to control scoring influence.");
|
||||
}
|
||||
|
||||
var hasReachability = profile.Signals.Any(s =>
|
||||
s.Name.Equals("reachability", StringComparison.OrdinalIgnoreCase));
|
||||
if (!hasReachability)
|
||||
{
|
||||
recommendations.Add("Consider adding a reachability signal to prioritize exploitable vulnerabilities.");
|
||||
}
|
||||
|
||||
if (recommendations.Count == 0)
|
||||
{
|
||||
recommendations.Add("Risk profile validated successfully; ready for use.");
|
||||
}
|
||||
|
||||
return recommendations.ToImmutable();
|
||||
}
|
||||
|
||||
private static IEnumerable<RiskProfileIssue> ExtractSchemaErrors(ValidationResults results)
|
||||
{
|
||||
if (results.Details != null)
|
||||
{
|
||||
foreach (var detail in results.Details)
|
||||
{
|
||||
if (detail.HasErrors)
|
||||
{
|
||||
foreach (var error in detail.Errors ?? [])
|
||||
{
|
||||
yield return RiskProfileIssue.Error(
|
||||
"RISK003",
|
||||
error.Value ?? "Schema validation failed",
|
||||
detail.EvaluationPath?.ToString() ?? "/");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(results.Message))
|
||||
{
|
||||
yield return RiskProfileIssue.Error("RISK003", results.Message, "/");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsValidSemVer(string version)
|
||||
{
|
||||
var parts = version.Split(['-', '+'], 2);
|
||||
return Version.TryParse(parts[0], out _);
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,15 @@
|
||||
<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" />
|
||||
<EmbeddedResource Include="Schemas\spl-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\spl-sample@1.json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Schemas\policy-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\policy-scoring-default.json" />
|
||||
<EmbeddedResource Include="Schemas\policy-scoring-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\spl-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\spl-sample@1.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for persisting and retrieving risk profiles.
|
||||
/// </summary>
|
||||
public interface IRiskProfileRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a risk profile by ID.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The profile, or null if not found.</returns>
|
||||
Task<RiskProfileModel?> GetAsync(string profileId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific version of a risk profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="version">The semantic version.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The profile version, or null if not found.</returns>
|
||||
Task<RiskProfileModel?> GetVersionAsync(string profileId, string version, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest version of a risk profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The latest profile version, or null if not found.</returns>
|
||||
Task<RiskProfileModel?> GetLatestAsync(string profileId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all available risk profile IDs.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collection of profile IDs.</returns>
|
||||
Task<IReadOnlyList<string>> ListProfileIdsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all versions of a risk profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collection of profile versions ordered by version descending.</returns>
|
||||
Task<IReadOnlyList<RiskProfileModel>> ListVersionsAsync(string profileId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Saves a risk profile.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to save.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if saved successfully, false if version conflict.</returns>
|
||||
Task<bool> SaveAsync(RiskProfileModel profile, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a specific version of a risk profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="version">The version to delete.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if deleted, false if not found.</returns>
|
||||
Task<bool> DeleteVersionAsync(string profileId, string version, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all versions of a risk profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if deleted, false if not found.</returns>
|
||||
Task<bool> DeleteAllVersionsAsync(string profileId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a profile exists.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the profile exists.</returns>
|
||||
Task<bool> ExistsAsync(string profileId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of risk profile repository for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRiskProfileRepository : IRiskProfileRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, RiskProfileModel>> _profiles = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task<RiskProfileModel?> GetAsync(string profileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return GetLatestAsync(profileId, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RiskProfileModel?> GetVersionAsync(string profileId, string version, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_profiles.TryGetValue(profileId, out var versions) &&
|
||||
versions.TryGetValue(version, out var profile))
|
||||
{
|
||||
return Task.FromResult<RiskProfileModel?>(CloneProfile(profile));
|
||||
}
|
||||
|
||||
return Task.FromResult<RiskProfileModel?>(null);
|
||||
}
|
||||
|
||||
public Task<RiskProfileModel?> GetLatestAsync(string profileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_profiles.TryGetValue(profileId, out var versions) || versions.IsEmpty)
|
||||
{
|
||||
return Task.FromResult<RiskProfileModel?>(null);
|
||||
}
|
||||
|
||||
var latest = versions.Values
|
||||
.OrderByDescending(p => ParseVersion(p.Version))
|
||||
.FirstOrDefault();
|
||||
|
||||
return Task.FromResult(latest != null ? CloneProfile(latest) : null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> ListProfileIdsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = _profiles.Keys.ToList().AsReadOnly();
|
||||
return Task.FromResult<IReadOnlyList<string>>(ids);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RiskProfileModel>> ListVersionsAsync(string profileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_profiles.TryGetValue(profileId, out var versions))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<RiskProfileModel>>(Array.Empty<RiskProfileModel>());
|
||||
}
|
||||
|
||||
var list = versions.Values
|
||||
.OrderByDescending(p => ParseVersion(p.Version))
|
||||
.Select(CloneProfile)
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<RiskProfileModel>>(list);
|
||||
}
|
||||
|
||||
public Task<bool> SaveAsync(RiskProfileModel profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
var versions = _profiles.GetOrAdd(profile.Id, _ => new ConcurrentDictionary<string, RiskProfileModel>(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
if (versions.ContainsKey(profile.Version))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
versions[profile.Version] = CloneProfile(profile);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteVersionAsync(string profileId, string version, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_profiles.TryGetValue(profileId, out var versions))
|
||||
{
|
||||
var removed = versions.TryRemove(version, out _);
|
||||
|
||||
if (versions.IsEmpty)
|
||||
{
|
||||
_profiles.TryRemove(profileId, out _);
|
||||
}
|
||||
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAllVersionsAsync(string profileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var removed = _profiles.TryRemove(profileId, out _);
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string profileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var exists = _profiles.TryGetValue(profileId, out var versions) && !versions.IsEmpty;
|
||||
return Task.FromResult(exists);
|
||||
}
|
||||
|
||||
private static Version ParseVersion(string version)
|
||||
{
|
||||
if (Version.TryParse(version, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
var parts = version.Split(['-', '+'], 2);
|
||||
if (parts.Length > 0 && Version.TryParse(parts[0], out parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
private static RiskProfileModel CloneProfile(RiskProfileModel source)
|
||||
{
|
||||
return new RiskProfileModel
|
||||
{
|
||||
Id = source.Id,
|
||||
Version = source.Version,
|
||||
Description = source.Description,
|
||||
Extends = source.Extends,
|
||||
Signals = source.Signals.Select(s => new RiskSignal
|
||||
{
|
||||
Name = s.Name,
|
||||
Source = s.Source,
|
||||
Type = s.Type,
|
||||
Path = s.Path,
|
||||
Transform = s.Transform,
|
||||
Unit = s.Unit
|
||||
}).ToList(),
|
||||
Weights = new Dictionary<string, double>(source.Weights),
|
||||
Overrides = new RiskOverrides
|
||||
{
|
||||
Severity = source.Overrides.Severity.Select(r => new SeverityOverride
|
||||
{
|
||||
When = new Dictionary<string, object>(r.When),
|
||||
Set = r.Set
|
||||
}).ToList(),
|
||||
Decisions = source.Overrides.Decisions.Select(r => new DecisionOverride
|
||||
{
|
||||
When = new Dictionary<string, object>(r.When),
|
||||
Action = r.Action,
|
||||
Reason = r.Reason
|
||||
}).ToList()
|
||||
},
|
||||
Metadata = source.Metadata != null
|
||||
? new Dictionary<string, object?>(source.Metadata)
|
||||
: null
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user