Gaps fill up, fixes, ui restructuring
This commit is contained in:
@@ -15,6 +15,7 @@ using StellaOps.Policy.Engine.Events;
|
||||
using StellaOps.Policy.Engine.ExceptionCache;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Gates;
|
||||
using StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
using StellaOps.Policy.Engine.Scoring.EvidenceWeightedScore;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
@@ -232,6 +233,28 @@ public static class PolicyEngineServiceCollectionExtensions
|
||||
return services.AddPolicyDecisionAttestation();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the execution evidence policy gate.
|
||||
/// Enforces that artifacts have signed execution evidence before promotion.
|
||||
/// Sprint: SPRINT_20260219_013 (SEE-03)
|
||||
/// </summary>
|
||||
public static IServiceCollection AddExecutionEvidenceGate(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<IContextPolicyGate, ExecutionEvidenceGate>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the beacon rate policy gate.
|
||||
/// Enforces minimum beacon verification rate for runtime canary coverage.
|
||||
/// Sprint: SPRINT_20260219_014 (BEA-03)
|
||||
/// </summary>
|
||||
public static IServiceCollection AddBeaconRateGate(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<IContextPolicyGate, BeaconRateGate>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds build gate evaluators for exception recheck policies.
|
||||
/// </summary>
|
||||
@@ -328,6 +351,11 @@ public static class PolicyEngineServiceCollectionExtensions
|
||||
// Always registered; activation controlled by PolicyEvidenceWeightedScoreOptions.Enabled
|
||||
services.AddEvidenceWeightedScore();
|
||||
|
||||
// Execution evidence and beacon rate gates (Sprint: SPRINT_20260219_013/014)
|
||||
// Always registered; activation controlled by PolicyGateOptions.ExecutionEvidence.Enabled / BeaconRate.Enabled
|
||||
services.AddExecutionEvidenceGate();
|
||||
services.AddBeaconRateGate();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
115
src/Policy/StellaOps.Policy.Engine/Gates/BeaconRateGate.cs
Normal file
115
src/Policy/StellaOps.Policy.Engine/Gates/BeaconRateGate.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Gates;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that enforces beacon verification rate thresholds.
|
||||
/// When enabled, blocks or warns for releases where beacon coverage is insufficient.
|
||||
/// Sprint: SPRINT_20260219_014 (BEA-03)
|
||||
/// </summary>
|
||||
public sealed class BeaconRateGate : IContextPolicyGate
|
||||
{
|
||||
private readonly IOptionsMonitor<PolicyGateOptions> _options;
|
||||
private readonly ILogger<BeaconRateGate> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public string Id => "beacon-rate";
|
||||
public string DisplayName => "Beacon Verification Rate";
|
||||
public string Description => "Enforces minimum beacon verification rate for runtime canary coverage.";
|
||||
|
||||
public BeaconRateGate(
|
||||
IOptionsMonitor<PolicyGateOptions> options,
|
||||
ILogger<BeaconRateGate> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
|
||||
{
|
||||
var gateOpts = _options.CurrentValue.BeaconRate;
|
||||
|
||||
if (!gateOpts.Enabled)
|
||||
{
|
||||
return Task.FromResult(GateResult.Pass(Id, "Beacon rate gate is disabled"));
|
||||
}
|
||||
|
||||
var environment = context.Environment ?? "unknown";
|
||||
|
||||
// Check if environment requires beacon coverage.
|
||||
if (!gateOpts.RequiredEnvironments.Contains(environment, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(GateResult.Pass(Id, $"Beacon rate not required for environment '{environment}'"));
|
||||
}
|
||||
|
||||
// Read beacon data from context metadata.
|
||||
double verificationRate = 0;
|
||||
var hasBeaconData = context.Metadata?.TryGetValue("beacon_verification_rate", out var rateStr) == true
|
||||
&& double.TryParse(rateStr, CultureInfo.InvariantCulture, out verificationRate);
|
||||
|
||||
if (!hasBeaconData)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"BeaconRateGate: no beacon data for environment {Environment}, subject {Subject}",
|
||||
environment, context.SubjectKey);
|
||||
|
||||
return gateOpts.MissingBeaconAction == PolicyGateDecisionType.Block
|
||||
? Task.FromResult(GateResult.Fail(Id, "No beacon telemetry data available for this artifact",
|
||||
ImmutableDictionary<string, object>.Empty
|
||||
.Add("environment", environment)
|
||||
.Add("suggestion", "Ensure beacon instrumentation is active in the target environment")))
|
||||
: Task.FromResult(GateResult.Pass(Id, "No beacon data available (warn mode)",
|
||||
new[] { "No beacon telemetry found; ensure runtime canary beacons are deployed" }));
|
||||
}
|
||||
|
||||
// Check minimum beacon count before enforcing rate.
|
||||
int beaconCount = 0;
|
||||
var hasBeaconCount = context.Metadata?.TryGetValue("beacon_verified_count", out var countStr) == true
|
||||
&& int.TryParse(countStr, out beaconCount);
|
||||
|
||||
if (hasBeaconCount && beaconCount < gateOpts.MinBeaconCount)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"BeaconRateGate: insufficient beacon count ({Count} < {Min}) for {Environment}; skipping rate check",
|
||||
beaconCount, gateOpts.MinBeaconCount, environment);
|
||||
|
||||
return Task.FromResult(GateResult.Pass(Id,
|
||||
$"Beacon count ({beaconCount}) below minimum ({gateOpts.MinBeaconCount}); rate enforcement deferred",
|
||||
new[] { $"Only {beaconCount} beacons observed; need {gateOpts.MinBeaconCount} before rate enforcement applies" }));
|
||||
}
|
||||
|
||||
// Evaluate rate against threshold.
|
||||
if (verificationRate < gateOpts.MinVerificationRate)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"BeaconRateGate: rate {Rate:F4} below threshold {Threshold:F4} for {Environment}, subject {Subject}",
|
||||
verificationRate, gateOpts.MinVerificationRate, environment, context.SubjectKey);
|
||||
|
||||
var details = ImmutableDictionary<string, object>.Empty
|
||||
.Add("verification_rate", verificationRate)
|
||||
.Add("threshold", gateOpts.MinVerificationRate)
|
||||
.Add("environment", environment);
|
||||
|
||||
return gateOpts.BelowThresholdAction == PolicyGateDecisionType.Block
|
||||
? Task.FromResult(GateResult.Fail(Id,
|
||||
$"Beacon verification rate ({verificationRate:P1}) is below threshold ({gateOpts.MinVerificationRate:P1})",
|
||||
details.Add("suggestion", "Investigate beacon gaps; possible deployment drift or instrumentation failure")))
|
||||
: Task.FromResult(GateResult.Pass(Id,
|
||||
$"Beacon verification rate ({verificationRate:P1}) is below threshold (warn mode)",
|
||||
new[] { $"Beacon rate {verificationRate:P1} < {gateOpts.MinVerificationRate:P1}; investigate potential gaps" }));
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"BeaconRateGate: passed for {Environment}, rate={Rate:F4}, subject {Subject}",
|
||||
environment, verificationRate, context.SubjectKey);
|
||||
|
||||
return Task.FromResult(GateResult.Pass(Id,
|
||||
$"Beacon verification rate ({verificationRate:P1}) meets threshold ({gateOpts.MinVerificationRate:P1})"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Gates;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that enforces execution evidence requirements.
|
||||
/// When enabled, blocks or warns for releases without signed execution evidence.
|
||||
/// Sprint: SPRINT_20260219_013 (SEE-03)
|
||||
/// </summary>
|
||||
public sealed class ExecutionEvidenceGate : IContextPolicyGate
|
||||
{
|
||||
private readonly IOptionsMonitor<PolicyGateOptions> _options;
|
||||
private readonly ILogger<ExecutionEvidenceGate> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public string Id => "execution-evidence";
|
||||
public string DisplayName => "Execution Evidence";
|
||||
public string Description => "Requires signed execution evidence (runtime trace attestation) for production releases.";
|
||||
|
||||
public ExecutionEvidenceGate(
|
||||
IOptionsMonitor<PolicyGateOptions> options,
|
||||
ILogger<ExecutionEvidenceGate> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
|
||||
{
|
||||
var gateOpts = _options.CurrentValue.ExecutionEvidence;
|
||||
|
||||
if (!gateOpts.Enabled)
|
||||
{
|
||||
return Task.FromResult(GateResult.Pass(Id, "Execution evidence gate is disabled"));
|
||||
}
|
||||
|
||||
var environment = context.Environment ?? "unknown";
|
||||
|
||||
// Check if environment requires execution evidence.
|
||||
if (!gateOpts.RequiredEnvironments.Contains(environment, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(GateResult.Pass(Id, $"Execution evidence not required for environment '{environment}'"));
|
||||
}
|
||||
|
||||
// Check metadata for execution evidence fields.
|
||||
var hasEvidence = context.Metadata?.TryGetValue("has_execution_evidence", out var evidenceStr) == true
|
||||
&& string.Equals(evidenceStr, "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!hasEvidence)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"ExecutionEvidenceGate: missing execution evidence for environment {Environment}, subject {Subject}",
|
||||
environment, context.SubjectKey);
|
||||
|
||||
return gateOpts.MissingEvidenceAction == PolicyGateDecisionType.Block
|
||||
? Task.FromResult(GateResult.Fail(Id, "Signed execution evidence is required for production releases",
|
||||
ImmutableDictionary<string, object>.Empty
|
||||
.Add("environment", environment)
|
||||
.Add("suggestion", "Run the execution evidence pipeline before promoting to production")))
|
||||
: Task.FromResult(GateResult.Pass(Id, "Execution evidence missing (warn mode)",
|
||||
new[] { "No signed execution evidence found; consider running the trace pipeline" }));
|
||||
}
|
||||
|
||||
// Validate evidence quality if hot symbol count is provided.
|
||||
if (context.Metadata?.TryGetValue("execution_evidence_hot_symbol_count", out var hotStr) == true
|
||||
&& int.TryParse(hotStr, out var hotCount)
|
||||
&& hotCount < gateOpts.MinHotSymbolCount)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"ExecutionEvidenceGate: insufficient hot symbols ({Count} < {Min}) for environment {Environment}",
|
||||
hotCount, gateOpts.MinHotSymbolCount, environment);
|
||||
|
||||
return Task.FromResult(GateResult.Pass(Id,
|
||||
$"Execution evidence has insufficient coverage ({hotCount} hot symbols < {gateOpts.MinHotSymbolCount} minimum)",
|
||||
new[] { $"Execution evidence trace has only {hotCount} hot symbols; minimum is {gateOpts.MinHotSymbolCount}" }));
|
||||
}
|
||||
|
||||
// Validate unique call paths if provided.
|
||||
if (context.Metadata?.TryGetValue("execution_evidence_unique_call_paths", out var pathStr) == true
|
||||
&& int.TryParse(pathStr, out var pathCount)
|
||||
&& pathCount < gateOpts.MinUniqueCallPaths)
|
||||
{
|
||||
return Task.FromResult(GateResult.Pass(Id,
|
||||
$"Execution evidence has insufficient call path coverage ({pathCount} < {gateOpts.MinUniqueCallPaths})",
|
||||
new[] { $"Execution evidence covers only {pathCount} unique call paths; minimum is {gateOpts.MinUniqueCallPaths}" }));
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"ExecutionEvidenceGate: passed for environment {Environment}, subject {Subject}",
|
||||
environment, context.SubjectKey);
|
||||
|
||||
return Task.FromResult(GateResult.Pass(Id, "Signed execution evidence is present and meets quality thresholds"));
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,34 @@ public sealed record PolicyGateEvidence
|
||||
/// </summary>
|
||||
[JsonPropertyName("pathLength")]
|
||||
public int? PathLength { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether signed execution evidence exists for this artifact.
|
||||
/// Sprint: SPRINT_20260219_013 (SEE-03)
|
||||
/// </summary>
|
||||
[JsonPropertyName("hasExecutionEvidence")]
|
||||
public bool HasExecutionEvidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Execution evidence predicate digest (sha256:...), if available.
|
||||
/// Sprint: SPRINT_20260219_013 (SEE-03)
|
||||
/// </summary>
|
||||
[JsonPropertyName("executionEvidenceDigest")]
|
||||
public string? ExecutionEvidenceDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Beacon verification rate (0.0 - 1.0), if beacons are observed.
|
||||
/// Sprint: SPRINT_20260219_014 (BEA-03)
|
||||
/// </summary>
|
||||
[JsonPropertyName("beaconVerificationRate")]
|
||||
public double? BeaconVerificationRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total beacons verified in the lookback window.
|
||||
/// Sprint: SPRINT_20260219_014 (BEA-03)
|
||||
/// </summary>
|
||||
[JsonPropertyName("beaconCount")]
|
||||
public int? BeaconCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -366,4 +394,45 @@ public sealed record PolicyGateRequest
|
||||
/// Signature verification method (dsse, cosign, pgp, x509).
|
||||
/// </summary>
|
||||
public string? VexSignatureMethod { get; init; }
|
||||
|
||||
// Execution evidence fields (added for ExecutionEvidenceGate integration)
|
||||
// Sprint: SPRINT_20260219_013 (SEE-03)
|
||||
|
||||
/// <summary>
|
||||
/// Whether signed execution evidence exists for this artifact.
|
||||
/// </summary>
|
||||
public bool HasExecutionEvidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Execution evidence predicate digest (sha256:...).
|
||||
/// </summary>
|
||||
public string? ExecutionEvidenceDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of hot symbols in the execution evidence trace summary.
|
||||
/// </summary>
|
||||
public int? ExecutionEvidenceHotSymbolCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of unique call paths in the execution evidence.
|
||||
/// </summary>
|
||||
public int? ExecutionEvidenceUniqueCallPaths { get; init; }
|
||||
|
||||
// Beacon attestation fields (added for BeaconRateGate integration)
|
||||
// Sprint: SPRINT_20260219_014 (BEA-03)
|
||||
|
||||
/// <summary>
|
||||
/// Beacon verification rate (0.0 - 1.0) for this artifact/environment.
|
||||
/// </summary>
|
||||
public double? BeaconVerificationRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total beacons verified in the lookback window.
|
||||
/// </summary>
|
||||
public int? BeaconVerifiedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total beacons expected in the lookback window.
|
||||
/// </summary>
|
||||
public int? BeaconExpectedCount { get; init; }
|
||||
}
|
||||
|
||||
@@ -43,6 +43,18 @@ public sealed class PolicyGateOptions
|
||||
/// </summary>
|
||||
public FacetQuotaGateOptions FacetQuota { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Execution evidence gate options.
|
||||
/// Sprint: SPRINT_20260219_013 (SEE-03)
|
||||
/// </summary>
|
||||
public ExecutionEvidenceGateOptions ExecutionEvidence { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Beacon verification rate gate options.
|
||||
/// Sprint: SPRINT_20260219_014 (BEA-03)
|
||||
/// </summary>
|
||||
public BeaconRateGateOptions BeaconRate { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Whether gates are enabled.
|
||||
/// </summary>
|
||||
@@ -216,3 +228,80 @@ public sealed class FacetQuotaOverride
|
||||
/// </summary>
|
||||
public List<string> AllowlistGlobs { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the execution evidence gate.
|
||||
/// When enabled, requires signed execution evidence for production releases.
|
||||
/// Sprint: SPRINT_20260219_013 (SEE-03)
|
||||
/// </summary>
|
||||
public sealed class ExecutionEvidenceGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether execution evidence enforcement is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Action when execution evidence is missing.
|
||||
/// </summary>
|
||||
public PolicyGateDecisionType MissingEvidenceAction { get; set; } = PolicyGateDecisionType.Warn;
|
||||
|
||||
/// <summary>
|
||||
/// Environments where execution evidence is required (case-insensitive).
|
||||
/// Default: production only.
|
||||
/// </summary>
|
||||
public List<string> RequiredEnvironments { get; set; } = new() { "production" };
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of hot symbols to consider evidence meaningful.
|
||||
/// Prevents trivial evidence from satisfying the gate.
|
||||
/// </summary>
|
||||
public int MinHotSymbolCount { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of unique call paths to consider evidence meaningful.
|
||||
/// </summary>
|
||||
public int MinUniqueCallPaths { get; set; } = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the beacon verification rate gate.
|
||||
/// When enabled, enforces minimum beacon coverage thresholds.
|
||||
/// Sprint: SPRINT_20260219_014 (BEA-03)
|
||||
/// </summary>
|
||||
public sealed class BeaconRateGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether beacon rate enforcement is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Action when beacon rate is below threshold.
|
||||
/// </summary>
|
||||
public PolicyGateDecisionType BelowThresholdAction { get; set; } = PolicyGateDecisionType.Warn;
|
||||
|
||||
/// <summary>
|
||||
/// Action when no beacon data exists for the artifact.
|
||||
/// </summary>
|
||||
public PolicyGateDecisionType MissingBeaconAction { get; set; } = PolicyGateDecisionType.Warn;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum beacon verification rate (0.0 - 1.0).
|
||||
/// Beacon rate below this triggers the BelowThresholdAction.
|
||||
/// Default: 0.8 (80% of expected beacons must be observed).
|
||||
/// </summary>
|
||||
public double MinVerificationRate { get; set; } = 0.8;
|
||||
|
||||
/// <summary>
|
||||
/// Environments where beacon rate is enforced (case-insensitive).
|
||||
/// Default: production only.
|
||||
/// </summary>
|
||||
public List<string> RequiredEnvironments { get; set; } = new() { "production" };
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of beacons observed before rate enforcement applies.
|
||||
/// Prevents premature gating on insufficient data.
|
||||
/// </summary>
|
||||
public int MinBeaconCount { get; set; } = 10;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Advisory-source policy endpoints (impact and conflict facts).
|
||||
/// </summary>
|
||||
public static class AdvisorySourceEndpoints
|
||||
{
|
||||
private static readonly HashSet<string> AllowedConflictStatuses = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"open",
|
||||
"resolved",
|
||||
"dismissed"
|
||||
};
|
||||
|
||||
public static void MapAdvisorySourcePolicyEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/advisory-sources")
|
||||
.WithTags("Advisory Sources");
|
||||
|
||||
group.MapGet("/{sourceId}/impact", GetImpactAsync)
|
||||
.WithName("GetAdvisorySourceImpact")
|
||||
.WithDescription("Get policy impact facts for an advisory source.")
|
||||
.Produces<AdvisorySourceImpactResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead));
|
||||
|
||||
group.MapGet("/{sourceId}/conflicts", GetConflictsAsync)
|
||||
.WithName("GetAdvisorySourceConflicts")
|
||||
.WithDescription("Get active/resolved advisory conflicts for a source.")
|
||||
.Produces<AdvisorySourceConflictListResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetImpactAsync(
|
||||
HttpContext httpContext,
|
||||
[FromRoute] string sourceId,
|
||||
[FromQuery] string? region,
|
||||
[FromQuery] string? environment,
|
||||
[FromQuery] string? sourceFamily,
|
||||
[FromServices] IAdvisorySourcePolicyReadRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryGetTenantId(httpContext, out var tenantId))
|
||||
{
|
||||
return TenantMissingProblem();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sourceId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "sourceId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var normalizedSourceId = sourceId.Trim();
|
||||
var normalizedRegion = NormalizeOptional(region);
|
||||
var normalizedEnvironment = NormalizeOptional(environment);
|
||||
var normalizedSourceFamily = NormalizeOptional(sourceFamily);
|
||||
|
||||
var impact = await repository.GetImpactAsync(
|
||||
tenantId,
|
||||
normalizedSourceId,
|
||||
normalizedRegion,
|
||||
normalizedEnvironment,
|
||||
normalizedSourceFamily,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new AdvisorySourceImpactResponse
|
||||
{
|
||||
SourceId = normalizedSourceId,
|
||||
SourceFamily = impact.SourceFamily ?? normalizedSourceFamily ?? string.Empty,
|
||||
Region = normalizedRegion,
|
||||
Environment = normalizedEnvironment,
|
||||
ImpactedDecisionsCount = impact.ImpactedDecisionsCount,
|
||||
ImpactSeverity = impact.ImpactSeverity,
|
||||
LastDecisionAt = impact.LastDecisionAt,
|
||||
DecisionRefs = ParseDecisionRefs(impact.DecisionRefsJson),
|
||||
DataAsOf = timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetConflictsAsync(
|
||||
HttpContext httpContext,
|
||||
[FromRoute] string sourceId,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
[FromServices] IAdvisorySourcePolicyReadRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryGetTenantId(httpContext, out var tenantId))
|
||||
{
|
||||
return TenantMissingProblem();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sourceId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "sourceId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var normalizedStatus = NormalizeOptional(status) ?? "open";
|
||||
if (!AllowedConflictStatuses.Contains(normalizedStatus))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "status must be one of: open, resolved, dismissed.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var normalizedSourceId = sourceId.Trim();
|
||||
var normalizedLimit = Math.Clamp(limit ?? 50, 1, 200);
|
||||
var normalizedOffset = Math.Max(offset ?? 0, 0);
|
||||
|
||||
var page = await repository.ListConflictsAsync(
|
||||
tenantId,
|
||||
normalizedSourceId,
|
||||
normalizedStatus,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var items = page.Items.Select(static item => new AdvisorySourceConflictResponse
|
||||
{
|
||||
ConflictId = item.ConflictId,
|
||||
AdvisoryId = item.AdvisoryId,
|
||||
PairedSourceKey = item.PairedSourceKey,
|
||||
ConflictType = item.ConflictType,
|
||||
Severity = item.Severity,
|
||||
Status = item.Status,
|
||||
Description = item.Description,
|
||||
FirstDetectedAt = item.FirstDetectedAt,
|
||||
LastDetectedAt = item.LastDetectedAt,
|
||||
ResolvedAt = item.ResolvedAt,
|
||||
Details = ParseDetails(item.DetailsJson)
|
||||
}).ToList();
|
||||
|
||||
return Results.Ok(new AdvisorySourceConflictListResponse
|
||||
{
|
||||
SourceId = normalizedSourceId,
|
||||
Status = normalizedStatus,
|
||||
Limit = normalizedLimit,
|
||||
Offset = normalizedOffset,
|
||||
TotalCount = page.TotalCount,
|
||||
Items = items,
|
||||
DataAsOf = timeProvider.GetUtcNow()
|
||||
});
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static bool TryGetTenantId(HttpContext httpContext, out string tenantId)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
|
||||
var claimTenant = httpContext.User.Claims.FirstOrDefault(claim => claim.Type == "tenant_id")?.Value;
|
||||
if (!string.IsNullOrWhiteSpace(claimTenant))
|
||||
{
|
||||
tenantId = claimTenant.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
var stellaHeaderTenant = httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(stellaHeaderTenant))
|
||||
{
|
||||
tenantId = stellaHeaderTenant.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
var tenantHeader = httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(tenantHeader))
|
||||
{
|
||||
tenantId = tenantHeader.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IResult TenantMissingProblem()
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Tenant header required.",
|
||||
detail: "Provide tenant via X-StellaOps-Tenant, X-Tenant-Id, or tenant_id claim.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdvisorySourceDecisionRef> ParseDecisionRefs(string decisionRefsJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(decisionRefsJson))
|
||||
{
|
||||
return Array.Empty<AdvisorySourceDecisionRef>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(decisionRefsJson);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<AdvisorySourceDecisionRef>();
|
||||
}
|
||||
|
||||
var refs = new List<AdvisorySourceDecisionRef>();
|
||||
foreach (var item in document.RootElement.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
refs.Add(new AdvisorySourceDecisionRef
|
||||
{
|
||||
DecisionId = TryGetString(item, "decisionId") ?? TryGetString(item, "decision_id") ?? string.Empty,
|
||||
DecisionType = TryGetString(item, "decisionType") ?? TryGetString(item, "decision_type"),
|
||||
Label = TryGetString(item, "label"),
|
||||
Route = TryGetString(item, "route")
|
||||
});
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Array.Empty<AdvisorySourceDecisionRef>();
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonElement? ParseDetails(string detailsJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(detailsJson))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(detailsJson);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryGetString(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
|
||||
? property.GetString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AdvisorySourceImpactResponse
|
||||
{
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
public string SourceFamily { get; init; } = string.Empty;
|
||||
public string? Region { get; init; }
|
||||
public string? Environment { get; init; }
|
||||
public int ImpactedDecisionsCount { get; init; }
|
||||
public string ImpactSeverity { get; init; } = "none";
|
||||
public DateTimeOffset? LastDecisionAt { get; init; }
|
||||
public IReadOnlyList<AdvisorySourceDecisionRef> DecisionRefs { get; init; } = Array.Empty<AdvisorySourceDecisionRef>();
|
||||
public DateTimeOffset DataAsOf { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdvisorySourceDecisionRef
|
||||
{
|
||||
public string DecisionId { get; init; } = string.Empty;
|
||||
public string? DecisionType { get; init; }
|
||||
public string? Label { get; init; }
|
||||
public string? Route { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdvisorySourceConflictListResponse
|
||||
{
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = "open";
|
||||
public int Limit { get; init; }
|
||||
public int Offset { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
public IReadOnlyList<AdvisorySourceConflictResponse> Items { get; init; } = Array.Empty<AdvisorySourceConflictResponse>();
|
||||
public DateTimeOffset DataAsOf { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdvisorySourceConflictResponse
|
||||
{
|
||||
public Guid ConflictId { get; init; }
|
||||
public string AdvisoryId { get; init; } = string.Empty;
|
||||
public string? PairedSourceKey { get; init; }
|
||||
public string ConflictType { get; init; } = string.Empty;
|
||||
public string Severity { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public DateTimeOffset FirstDetectedAt { get; init; }
|
||||
public DateTimeOffset LastDetectedAt { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
public JsonElement? Details { get; init; }
|
||||
}
|
||||
@@ -649,6 +649,9 @@ app.MapExceptionApprovalEndpoints();
|
||||
// Governance endpoints (Sprint: SPRINT_20251229_021a_FE_policy_governance_controls, Task: GOV-018)
|
||||
app.MapGovernanceEndpoints();
|
||||
|
||||
// Advisory source impact/conflict endpoints (Sprint: SPRINT_20260219_008, Task: BE8-05)
|
||||
app.MapAdvisorySourcePolicyEndpoints();
|
||||
|
||||
// Assistant tool lattice endpoints (Sprint: SPRINT_20260113_005_POLICY_assistant_tool_lattice)
|
||||
app.MapToolLatticeEndpoints();
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ public static class PolicyPersistenceExtensions
|
||||
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
|
||||
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
|
||||
services.AddScoped<IConflictRepository, ConflictRepository>();
|
||||
services.AddScoped<IAdvisorySourcePolicyReadRepository, AdvisorySourcePolicyReadRepository>();
|
||||
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
|
||||
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
|
||||
|
||||
@@ -79,6 +80,7 @@ public static class PolicyPersistenceExtensions
|
||||
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
|
||||
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
|
||||
services.AddScoped<IConflictRepository, ConflictRepository>();
|
||||
services.AddScoped<IAdvisorySourcePolicyReadRepository, AdvisorySourcePolicyReadRepository>();
|
||||
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
|
||||
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
-- Policy Schema Migration 005: Advisory source impact/conflict projection
|
||||
-- Sprint: SPRINT_20260219_008
|
||||
-- Task: BE8-05
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy.advisory_source_impacts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
source_key TEXT NOT NULL,
|
||||
source_family TEXT NOT NULL DEFAULT '',
|
||||
region TEXT NOT NULL DEFAULT '',
|
||||
environment TEXT NOT NULL DEFAULT '',
|
||||
impacted_decisions_count INT NOT NULL DEFAULT 0 CHECK (impacted_decisions_count >= 0),
|
||||
impact_severity TEXT NOT NULL DEFAULT 'none' CHECK (impact_severity IN ('none', 'low', 'medium', 'high', 'critical')),
|
||||
last_decision_at TIMESTAMPTZ,
|
||||
decision_refs JSONB NOT NULL DEFAULT '[]',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by TEXT NOT NULL DEFAULT 'system'
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_advisory_source_impacts_scope
|
||||
ON policy.advisory_source_impacts (tenant_id, source_key, source_family, region, environment);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_advisory_source_impacts_lookup
|
||||
ON policy.advisory_source_impacts (tenant_id, source_key, impact_severity, updated_at DESC);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_advisory_source_impacts_updated_at ON policy.advisory_source_impacts;
|
||||
CREATE TRIGGER trg_advisory_source_impacts_updated_at
|
||||
BEFORE UPDATE ON policy.advisory_source_impacts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION policy.update_updated_at();
|
||||
|
||||
ALTER TABLE policy.advisory_source_impacts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY advisory_source_impacts_tenant_isolation ON policy.advisory_source_impacts
|
||||
FOR ALL
|
||||
USING (tenant_id = policy_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = policy_app.require_current_tenant());
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy.advisory_source_conflicts (
|
||||
conflict_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
source_key TEXT NOT NULL,
|
||||
source_family TEXT NOT NULL DEFAULT '',
|
||||
advisory_id TEXT NOT NULL,
|
||||
paired_source_key TEXT,
|
||||
conflict_type TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT 'medium' CHECK (severity IN ('critical', 'high', 'medium', 'low')),
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'dismissed')),
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
first_detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
details_json JSONB NOT NULL DEFAULT '{}',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by TEXT NOT NULL DEFAULT 'system'
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_advisory_source_conflicts_open
|
||||
ON policy.advisory_source_conflicts (tenant_id, source_key, advisory_id, conflict_type, COALESCE(paired_source_key, ''))
|
||||
WHERE status = 'open';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_advisory_source_conflicts_lookup
|
||||
ON policy.advisory_source_conflicts (tenant_id, source_key, status, severity, last_detected_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_advisory_source_conflicts_advisory
|
||||
ON policy.advisory_source_conflicts (tenant_id, advisory_id, status);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_advisory_source_conflicts_updated_at ON policy.advisory_source_conflicts;
|
||||
CREATE TRIGGER trg_advisory_source_conflicts_updated_at
|
||||
BEFORE UPDATE ON policy.advisory_source_conflicts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION policy.update_updated_at();
|
||||
|
||||
ALTER TABLE policy.advisory_source_conflicts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY advisory_source_conflicts_tenant_isolation ON policy.advisory_source_conflicts
|
||||
FOR ALL
|
||||
USING (tenant_id = policy_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = policy_app.require_current_tenant());
|
||||
|
||||
-- Best-effort backfill from legacy policy.conflicts rows that encode source scope as source:<key>.
|
||||
INSERT INTO policy.advisory_source_conflicts (
|
||||
tenant_id,
|
||||
source_key,
|
||||
source_family,
|
||||
advisory_id,
|
||||
paired_source_key,
|
||||
conflict_type,
|
||||
severity,
|
||||
status,
|
||||
description,
|
||||
first_detected_at,
|
||||
last_detected_at,
|
||||
details_json,
|
||||
updated_by
|
||||
)
|
||||
SELECT
|
||||
c.tenant_id,
|
||||
split_part(c.affected_scope, ':', 2) AS source_key,
|
||||
COALESCE(c.metadata->>'source_family', '') AS source_family,
|
||||
COALESCE(c.metadata->>'advisory_id', 'unknown') AS advisory_id,
|
||||
NULLIF(c.metadata->>'paired_source_key', '') AS paired_source_key,
|
||||
c.conflict_type,
|
||||
c.severity,
|
||||
c.status,
|
||||
c.description,
|
||||
c.created_at AS first_detected_at,
|
||||
COALESCE(c.resolved_at, c.created_at) AS last_detected_at,
|
||||
c.metadata AS details_json,
|
||||
'migration-005-backfill'
|
||||
FROM policy.conflicts c
|
||||
WHERE c.affected_scope LIKE 'source:%'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO policy.advisory_source_impacts (
|
||||
tenant_id,
|
||||
source_key,
|
||||
source_family,
|
||||
impacted_decisions_count,
|
||||
impact_severity,
|
||||
last_decision_at,
|
||||
decision_refs,
|
||||
updated_by
|
||||
)
|
||||
SELECT
|
||||
c.tenant_id,
|
||||
c.source_key,
|
||||
c.source_family,
|
||||
COUNT(*)::INT AS impacted_decisions_count,
|
||||
CASE MAX(
|
||||
CASE c.severity
|
||||
WHEN 'critical' THEN 4
|
||||
WHEN 'high' THEN 3
|
||||
WHEN 'medium' THEN 2
|
||||
WHEN 'low' THEN 1
|
||||
ELSE 0
|
||||
END)
|
||||
WHEN 4 THEN 'critical'
|
||||
WHEN 3 THEN 'high'
|
||||
WHEN 2 THEN 'medium'
|
||||
WHEN 1 THEN 'low'
|
||||
ELSE 'none'
|
||||
END AS impact_severity,
|
||||
MAX(c.last_detected_at) AS last_decision_at,
|
||||
'[]'::jsonb AS decision_refs,
|
||||
'migration-005-backfill'
|
||||
FROM policy.advisory_source_conflicts c
|
||||
WHERE c.status = 'open'
|
||||
GROUP BY c.tenant_id, c.source_key, c.source_family
|
||||
ON CONFLICT (tenant_id, source_key, source_family, region, environment) DO UPDATE
|
||||
SET
|
||||
impacted_decisions_count = EXCLUDED.impacted_decisions_count,
|
||||
impact_severity = EXCLUDED.impact_severity,
|
||||
last_decision_at = EXCLUDED.last_decision_at,
|
||||
updated_by = EXCLUDED.updated_by;
|
||||
@@ -0,0 +1,228 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed read model for advisory-source policy facts.
|
||||
/// </summary>
|
||||
public sealed class AdvisorySourcePolicyReadRepository
|
||||
: RepositoryBase<PolicyDataSource>, IAdvisorySourcePolicyReadRepository
|
||||
{
|
||||
public AdvisorySourcePolicyReadRepository(
|
||||
PolicyDataSource dataSource,
|
||||
ILogger<AdvisorySourcePolicyReadRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<AdvisorySourceImpactSnapshot> GetImpactAsync(
|
||||
string tenantId,
|
||||
string sourceKey,
|
||||
string? region,
|
||||
string? environment,
|
||||
string? sourceFamily,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
WITH filtered AS (
|
||||
SELECT
|
||||
source_key,
|
||||
source_family,
|
||||
region,
|
||||
environment,
|
||||
impacted_decisions_count,
|
||||
impact_severity,
|
||||
last_decision_at,
|
||||
updated_at,
|
||||
decision_refs
|
||||
FROM policy.advisory_source_impacts
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND lower(source_key) = lower(@source_key)
|
||||
AND (@region IS NULL OR lower(region) = lower(@region))
|
||||
AND (@environment IS NULL OR lower(environment) = lower(@environment))
|
||||
AND (@source_family IS NULL OR lower(source_family) = lower(@source_family))
|
||||
),
|
||||
aggregate_row AS (
|
||||
SELECT
|
||||
@source_key AS source_key,
|
||||
@source_family AS source_family_filter,
|
||||
@region AS region_filter,
|
||||
@environment AS environment_filter,
|
||||
COALESCE(SUM(impacted_decisions_count), 0)::INT AS impacted_decisions_count,
|
||||
COALESCE(MAX(
|
||||
CASE impact_severity
|
||||
WHEN 'critical' THEN 4
|
||||
WHEN 'high' THEN 3
|
||||
WHEN 'medium' THEN 2
|
||||
WHEN 'low' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
), 0) AS severity_rank,
|
||||
MAX(last_decision_at) AS last_decision_at,
|
||||
MAX(updated_at) AS updated_at,
|
||||
COALESCE(
|
||||
(SELECT decision_refs FROM filtered ORDER BY updated_at DESC NULLS LAST LIMIT 1),
|
||||
'[]'::jsonb
|
||||
)::TEXT AS decision_refs_json
|
||||
FROM filtered
|
||||
)
|
||||
SELECT
|
||||
source_key,
|
||||
source_family_filter,
|
||||
region_filter,
|
||||
environment_filter,
|
||||
impacted_decisions_count,
|
||||
CASE severity_rank
|
||||
WHEN 4 THEN 'critical'
|
||||
WHEN 3 THEN 'high'
|
||||
WHEN 2 THEN 'medium'
|
||||
WHEN 1 THEN 'low'
|
||||
ELSE 'none'
|
||||
END AS impact_severity,
|
||||
last_decision_at,
|
||||
updated_at,
|
||||
decision_refs_json
|
||||
FROM aggregate_row;
|
||||
""";
|
||||
|
||||
var normalizedSourceKey = NormalizeRequired(sourceKey, nameof(sourceKey));
|
||||
var normalizedRegion = NormalizeOptional(region);
|
||||
var normalizedEnvironment = NormalizeOptional(environment);
|
||||
var normalizedSourceFamily = NormalizeOptional(sourceFamily);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "source_key", normalizedSourceKey);
|
||||
AddParameter(command, "region", normalizedRegion);
|
||||
AddParameter(command, "environment", normalizedEnvironment);
|
||||
AddParameter(command, "source_family", normalizedSourceFamily);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new AdvisorySourceImpactSnapshot(
|
||||
SourceKey: reader.GetString(0),
|
||||
SourceFamily: GetNullableString(reader, 1),
|
||||
Region: GetNullableString(reader, 2),
|
||||
Environment: GetNullableString(reader, 3),
|
||||
ImpactedDecisionsCount: reader.GetInt32(4),
|
||||
ImpactSeverity: reader.GetString(5),
|
||||
LastDecisionAt: GetNullableDateTimeOffset(reader, 6),
|
||||
UpdatedAt: GetNullableDateTimeOffset(reader, 7),
|
||||
DecisionRefsJson: reader.GetString(8));
|
||||
}
|
||||
|
||||
public async Task<AdvisorySourceConflictPage> ListConflictsAsync(
|
||||
string tenantId,
|
||||
string sourceKey,
|
||||
string? status,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string countSql = """
|
||||
SELECT COUNT(*)::INT
|
||||
FROM policy.advisory_source_conflicts
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND lower(source_key) = lower(@source_key)
|
||||
AND (@status IS NULL OR status = @status)
|
||||
""";
|
||||
|
||||
const string listSql = """
|
||||
SELECT
|
||||
conflict_id,
|
||||
advisory_id,
|
||||
paired_source_key,
|
||||
conflict_type,
|
||||
severity,
|
||||
status,
|
||||
description,
|
||||
first_detected_at,
|
||||
last_detected_at,
|
||||
resolved_at,
|
||||
details_json::TEXT
|
||||
FROM policy.advisory_source_conflicts
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND lower(source_key) = lower(@source_key)
|
||||
AND (@status IS NULL OR status = @status)
|
||||
ORDER BY
|
||||
CASE severity
|
||||
WHEN 'critical' THEN 4
|
||||
WHEN 'high' THEN 3
|
||||
WHEN 'medium' THEN 2
|
||||
WHEN 'low' THEN 1
|
||||
ELSE 0
|
||||
END DESC,
|
||||
last_detected_at DESC,
|
||||
conflict_id
|
||||
LIMIT @limit
|
||||
OFFSET @offset
|
||||
""";
|
||||
|
||||
var normalizedSourceKey = NormalizeRequired(sourceKey, nameof(sourceKey));
|
||||
var normalizedStatus = NormalizeOptional(status);
|
||||
var normalizedLimit = Math.Clamp(limit, 1, 200);
|
||||
var normalizedOffset = Math.Max(offset, 0);
|
||||
|
||||
var totalCount = await ExecuteScalarAsync<int>(
|
||||
tenantId,
|
||||
countSql,
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "source_key", normalizedSourceKey);
|
||||
AddParameter(command, "status", normalizedStatus);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var items = await QueryAsync(
|
||||
tenantId,
|
||||
listSql,
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "source_key", normalizedSourceKey);
|
||||
AddParameter(command, "status", normalizedStatus);
|
||||
AddParameter(command, "limit", normalizedLimit);
|
||||
AddParameter(command, "offset", normalizedOffset);
|
||||
},
|
||||
MapConflict,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new AdvisorySourceConflictPage(items, totalCount);
|
||||
}
|
||||
|
||||
private static AdvisorySourceConflictRecord MapConflict(NpgsqlDataReader reader)
|
||||
{
|
||||
return new AdvisorySourceConflictRecord(
|
||||
ConflictId: reader.GetGuid(0),
|
||||
AdvisoryId: reader.GetString(1),
|
||||
PairedSourceKey: GetNullableString(reader, 2),
|
||||
ConflictType: reader.GetString(3),
|
||||
Severity: reader.GetString(4),
|
||||
Status: reader.GetString(5),
|
||||
Description: reader.GetString(6),
|
||||
FirstDetectedAt: reader.GetFieldValue<DateTimeOffset>(7),
|
||||
LastDetectedAt: reader.GetFieldValue<DateTimeOffset>(8),
|
||||
ResolvedAt: GetNullableDateTimeOffset(reader, 9),
|
||||
DetailsJson: reader.GetString(10));
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string value, string parameterName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException($"{parameterName} is required.", parameterName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Read-model access for Advisory Sources policy-owned impact and conflict facts.
|
||||
/// </summary>
|
||||
public interface IAdvisorySourcePolicyReadRepository
|
||||
{
|
||||
Task<AdvisorySourceImpactSnapshot> GetImpactAsync(
|
||||
string tenantId,
|
||||
string sourceKey,
|
||||
string? region,
|
||||
string? environment,
|
||||
string? sourceFamily,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdvisorySourceConflictPage> ListConflictsAsync(
|
||||
string tenantId,
|
||||
string sourceKey,
|
||||
string? status,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record AdvisorySourceImpactSnapshot(
|
||||
string SourceKey,
|
||||
string? SourceFamily,
|
||||
string? Region,
|
||||
string? Environment,
|
||||
int ImpactedDecisionsCount,
|
||||
string ImpactSeverity,
|
||||
DateTimeOffset? LastDecisionAt,
|
||||
DateTimeOffset? UpdatedAt,
|
||||
string DecisionRefsJson);
|
||||
|
||||
public sealed record AdvisorySourceConflictRecord(
|
||||
Guid ConflictId,
|
||||
string AdvisoryId,
|
||||
string? PairedSourceKey,
|
||||
string ConflictType,
|
||||
string Severity,
|
||||
string Status,
|
||||
string Description,
|
||||
DateTimeOffset FirstDetectedAt,
|
||||
DateTimeOffset LastDetectedAt,
|
||||
DateTimeOffset? ResolvedAt,
|
||||
string DetailsJson);
|
||||
|
||||
public sealed record AdvisorySourceConflictPage(
|
||||
IReadOnlyList<AdvisorySourceConflictRecord> Items,
|
||||
int TotalCount);
|
||||
@@ -48,6 +48,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
|
||||
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
|
||||
services.AddScoped<IConflictRepository, ConflictRepository>();
|
||||
services.AddScoped<IAdvisorySourcePolicyReadRepository, AdvisorySourcePolicyReadRepository>();
|
||||
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
|
||||
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
|
||||
|
||||
@@ -81,6 +82,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
|
||||
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
|
||||
services.AddScoped<IConflictRepository, ConflictRepository>();
|
||||
services.AddScoped<IAdvisorySourcePolicyReadRepository, AdvisorySourcePolicyReadRepository>();
|
||||
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
|
||||
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Gates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for BeaconRateGate.
|
||||
/// Sprint: SPRINT_20260219_014 (BEA-03)
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Sprint", "20260219.014")]
|
||||
public sealed class BeaconRateGateTests
|
||||
{
|
||||
private readonly TimeProvider _fixedTimeProvider = new FixedTimeProvider(
|
||||
new DateTimeOffset(2026, 2, 19, 14, 0, 0, TimeSpan.Zero));
|
||||
|
||||
#region Gate disabled
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenDisabled_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate(enabled: false);
|
||||
var context = CreateContext("production");
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("disabled", result.Reason!);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Environment filtering
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NonRequiredEnvironment_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate();
|
||||
var context = CreateContext("development");
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("not required", result.Reason!);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Missing beacon data
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingBeaconData_WarnMode_ReturnsPassWithWarning()
|
||||
{
|
||||
var gate = CreateGate(missingAction: PolicyGateDecisionType.Warn);
|
||||
var context = CreateContext("production");
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("warn", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingBeaconData_BlockMode_ReturnsFail()
|
||||
{
|
||||
var gate = CreateGate(missingAction: PolicyGateDecisionType.Block);
|
||||
var context = CreateContext("production");
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rate threshold enforcement
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RateAboveThreshold_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate(minRate: 0.8);
|
||||
var context = CreateContext("production", beaconRate: 0.95, beaconCount: 100);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("meets", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RateBelowThreshold_WarnMode_ReturnsPassWithWarning()
|
||||
{
|
||||
var gate = CreateGate(minRate: 0.8, belowAction: PolicyGateDecisionType.Warn);
|
||||
var context = CreateContext("production", beaconRate: 0.5, beaconCount: 100);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("below threshold", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RateBelowThreshold_BlockMode_ReturnsFail()
|
||||
{
|
||||
var gate = CreateGate(minRate: 0.8, belowAction: PolicyGateDecisionType.Block);
|
||||
var context = CreateContext("production", beaconRate: 0.5, beaconCount: 100);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Minimum beacon count
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_BelowMinBeaconCount_SkipsRateEnforcement()
|
||||
{
|
||||
var gate = CreateGate(minRate: 0.8, minBeaconCount: 50);
|
||||
// Rate is bad but count is too low to enforce.
|
||||
var context = CreateContext("production", beaconRate: 0.3, beaconCount: 5);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("deferred", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Boundary conditions
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ExactlyAtThreshold_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate(minRate: 0.8);
|
||||
var context = CreateContext("production", beaconRate: 0.8, beaconCount: 100);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_JustBelowThreshold_TriggersAction()
|
||||
{
|
||||
var gate = CreateGate(minRate: 0.8, belowAction: PolicyGateDecisionType.Block);
|
||||
var context = CreateContext("production", beaconRate: 0.7999, beaconCount: 100);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private BeaconRateGate CreateGate(
|
||||
bool enabled = true,
|
||||
double minRate = 0.8,
|
||||
int minBeaconCount = 10,
|
||||
PolicyGateDecisionType missingAction = PolicyGateDecisionType.Warn,
|
||||
PolicyGateDecisionType belowAction = PolicyGateDecisionType.Warn)
|
||||
{
|
||||
var opts = new PolicyGateOptions
|
||||
{
|
||||
BeaconRate = new BeaconRateGateOptions
|
||||
{
|
||||
Enabled = enabled,
|
||||
MinVerificationRate = minRate,
|
||||
MinBeaconCount = minBeaconCount,
|
||||
MissingBeaconAction = missingAction,
|
||||
BelowThresholdAction = belowAction,
|
||||
RequiredEnvironments = new List<string> { "production" },
|
||||
},
|
||||
};
|
||||
var monitor = new StaticOptionsMonitor<PolicyGateOptions>(opts);
|
||||
return new BeaconRateGate(monitor, NullLogger<BeaconRateGate>.Instance, _fixedTimeProvider);
|
||||
}
|
||||
|
||||
private static PolicyGateContext CreateContext(
|
||||
string environment,
|
||||
double? beaconRate = null,
|
||||
int? beaconCount = null)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>();
|
||||
if (beaconRate.HasValue)
|
||||
{
|
||||
metadata["beacon_verification_rate"] = beaconRate.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
if (beaconCount.HasValue)
|
||||
{
|
||||
metadata["beacon_verified_count"] = beaconCount.Value.ToString();
|
||||
}
|
||||
|
||||
return new PolicyGateContext
|
||||
{
|
||||
Environment = environment,
|
||||
SubjectKey = "test-subject",
|
||||
Metadata = metadata,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
private readonly T _value;
|
||||
|
||||
public StaticOptionsMonitor(T value) => _value = value;
|
||||
|
||||
public T CurrentValue => _value;
|
||||
public T Get(string? name) => _value;
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Gates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ExecutionEvidenceGate.
|
||||
/// Sprint: SPRINT_20260219_013 (SEE-03)
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Sprint", "20260219.013")]
|
||||
public sealed class ExecutionEvidenceGateTests
|
||||
{
|
||||
private readonly TimeProvider _fixedTimeProvider = new FixedTimeProvider(
|
||||
new DateTimeOffset(2026, 2, 19, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
#region Gate disabled
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenDisabled_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate(enabled: false);
|
||||
var context = CreateContext("production", hasEvidence: false);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("disabled", result.Reason!);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Environment filtering
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NonRequiredEnvironment_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate();
|
||||
var context = CreateContext("development", hasEvidence: false);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("not required", result.Reason!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequiredEnvironment_EnforcesEvidence()
|
||||
{
|
||||
var gate = CreateGate(missingAction: PolicyGateDecisionType.Block);
|
||||
var context = CreateContext("production", hasEvidence: false);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Contains("required", result.Reason!);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Evidence present
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_EvidencePresent_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate();
|
||||
var context = CreateContext("production", hasEvidence: true);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("present", result.Reason!);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Missing evidence actions
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingEvidence_WarnMode_ReturnsPassWithWarning()
|
||||
{
|
||||
var gate = CreateGate(missingAction: PolicyGateDecisionType.Warn);
|
||||
var context = CreateContext("production", hasEvidence: false);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("warn", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingEvidence_BlockMode_ReturnsFail()
|
||||
{
|
||||
var gate = CreateGate(missingAction: PolicyGateDecisionType.Block);
|
||||
var context = CreateContext("production", hasEvidence: false);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Quality checks
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_InsufficientHotSymbols_ReturnsPassWithWarning()
|
||||
{
|
||||
var gate = CreateGate(minHotSymbols: 10);
|
||||
var context = CreateContext("production", hasEvidence: true, hotSymbolCount: 2);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("insufficient", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SufficientHotSymbols_ReturnsCleanPass()
|
||||
{
|
||||
var gate = CreateGate(minHotSymbols: 3);
|
||||
var context = CreateContext("production", hasEvidence: true, hotSymbolCount: 15);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("meets", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private ExecutionEvidenceGate CreateGate(
|
||||
bool enabled = true,
|
||||
PolicyGateDecisionType missingAction = PolicyGateDecisionType.Warn,
|
||||
int minHotSymbols = 3,
|
||||
int minCallPaths = 1)
|
||||
{
|
||||
var opts = new PolicyGateOptions
|
||||
{
|
||||
ExecutionEvidence = new ExecutionEvidenceGateOptions
|
||||
{
|
||||
Enabled = enabled,
|
||||
MissingEvidenceAction = missingAction,
|
||||
MinHotSymbolCount = minHotSymbols,
|
||||
MinUniqueCallPaths = minCallPaths,
|
||||
RequiredEnvironments = new List<string> { "production" },
|
||||
},
|
||||
};
|
||||
var monitor = new StaticOptionsMonitor<PolicyGateOptions>(opts);
|
||||
return new ExecutionEvidenceGate(monitor, NullLogger<ExecutionEvidenceGate>.Instance, _fixedTimeProvider);
|
||||
}
|
||||
|
||||
private static PolicyGateContext CreateContext(
|
||||
string environment,
|
||||
bool hasEvidence,
|
||||
int? hotSymbolCount = null,
|
||||
int? uniqueCallPaths = null)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>();
|
||||
if (hasEvidence)
|
||||
{
|
||||
metadata["has_execution_evidence"] = "true";
|
||||
}
|
||||
if (hotSymbolCount.HasValue)
|
||||
{
|
||||
metadata["execution_evidence_hot_symbol_count"] = hotSymbolCount.Value.ToString();
|
||||
}
|
||||
if (uniqueCallPaths.HasValue)
|
||||
{
|
||||
metadata["execution_evidence_unique_call_paths"] = uniqueCallPaths.Value.ToString();
|
||||
}
|
||||
|
||||
return new PolicyGateContext
|
||||
{
|
||||
Environment = environment,
|
||||
SubjectKey = "test-subject",
|
||||
Metadata = metadata,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
private readonly T _value;
|
||||
|
||||
public StaticOptionsMonitor(T value) => _value = value;
|
||||
|
||||
public T CurrentValue => _value;
|
||||
public T Get(string? name) => _value;
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Gateway.Endpoints;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Tests;
|
||||
|
||||
public sealed class AdvisorySourceEndpointsTests : IClassFixture<TestPolicyGatewayFactory>
|
||||
{
|
||||
private readonly TestPolicyGatewayFactory _factory;
|
||||
|
||||
public AdvisorySourceEndpointsTests(TestPolicyGatewayFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetImpact_ReturnsPolicyImpactPayload()
|
||||
{
|
||||
using var client = CreateTenantClient();
|
||||
|
||||
var response = await client.GetAsync(
|
||||
"/api/v1/advisory-sources/nvd/impact?region=us-east&environment=prod&sourceFamily=nvd",
|
||||
CancellationToken.None);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var payload = await response.Content.ReadFromJsonAsync<AdvisorySourceImpactResponse>(cancellationToken: CancellationToken.None);
|
||||
payload.Should().NotBeNull();
|
||||
payload!.SourceId.Should().Be("nvd");
|
||||
payload.ImpactedDecisionsCount.Should().Be(4);
|
||||
payload.ImpactSeverity.Should().Be("high");
|
||||
payload.DecisionRefs.Should().ContainSingle();
|
||||
payload.DecisionRefs[0].DecisionId.Should().Be("APR-2201");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetImpact_WithoutTenant_ReturnsBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/advisory-sources/nvd/impact", CancellationToken.None);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetConflicts_DefaultStatus_ReturnsOpenConflicts()
|
||||
{
|
||||
using var client = CreateTenantClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/advisory-sources/nvd/conflicts", CancellationToken.None);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var payload = await response.Content.ReadFromJsonAsync<AdvisorySourceConflictListResponse>(cancellationToken: CancellationToken.None);
|
||||
payload.Should().NotBeNull();
|
||||
payload!.SourceId.Should().Be("nvd");
|
||||
payload.Status.Should().Be("open");
|
||||
payload.TotalCount.Should().Be(1);
|
||||
payload.Items.Should().ContainSingle();
|
||||
payload.Items[0].AdvisoryId.Should().Be("CVE-2026-1188");
|
||||
payload.Items[0].Status.Should().Be("open");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetConflicts_WithResolvedStatus_ReturnsResolvedConflicts()
|
||||
{
|
||||
using var client = CreateTenantClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/advisory-sources/nvd/conflicts?status=resolved", CancellationToken.None);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var payload = await response.Content.ReadFromJsonAsync<AdvisorySourceConflictListResponse>(cancellationToken: CancellationToken.None);
|
||||
payload.Should().NotBeNull();
|
||||
payload!.TotalCount.Should().Be(1);
|
||||
payload.Items.Should().ContainSingle();
|
||||
payload.Items[0].Status.Should().Be("resolved");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetConflicts_WithInvalidStatus_ReturnsBadRequest()
|
||||
{
|
||||
using var client = CreateTenantClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/advisory-sources/nvd/conflicts?status=invalid", CancellationToken.None);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
using System.Text.Json;
|
||||
using IAuditableExceptionRepository = StellaOps.Policy.Exceptions.Repositories.IExceptionRepository;
|
||||
using GatewayProgram = StellaOps.Policy.Gateway.Program;
|
||||
|
||||
@@ -82,6 +83,8 @@ public sealed class TestPolicyGatewayFactory : WebApplicationFactory<GatewayProg
|
||||
services.AddSingleton<IAuditableExceptionRepository, InMemoryExceptionRepository>();
|
||||
services.RemoveAll<IGateDecisionHistoryRepository>();
|
||||
services.AddSingleton<IGateDecisionHistoryRepository, InMemoryGateDecisionHistoryRepository>();
|
||||
services.RemoveAll<IAdvisorySourcePolicyReadRepository>();
|
||||
services.AddSingleton<IAdvisorySourcePolicyReadRepository, InMemoryAdvisorySourcePolicyReadRepository>();
|
||||
|
||||
// Override JWT bearer auth to accept test tokens without real OIDC discovery
|
||||
services.PostConfigure<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme, options =>
|
||||
@@ -330,3 +333,124 @@ internal sealed class InMemoryGateDecisionHistoryRepository : IGateDecisionHisto
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of advisory source policy read models for endpoint tests.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryAdvisorySourcePolicyReadRepository : IAdvisorySourcePolicyReadRepository
|
||||
{
|
||||
private readonly AdvisorySourceImpactSnapshot _impact = new(
|
||||
SourceKey: "nvd",
|
||||
SourceFamily: "nvd",
|
||||
Region: "us-east",
|
||||
Environment: "prod",
|
||||
ImpactedDecisionsCount: 4,
|
||||
ImpactSeverity: "high",
|
||||
LastDecisionAt: DateTimeOffset.Parse("2026-02-19T08:10:00Z"),
|
||||
UpdatedAt: DateTimeOffset.Parse("2026-02-19T08:11:00Z"),
|
||||
DecisionRefsJson: """
|
||||
[
|
||||
{
|
||||
"decisionId": "APR-2201",
|
||||
"decisionType": "approval",
|
||||
"label": "Approval APR-2201",
|
||||
"route": "/release-control/approvals/apr-2201"
|
||||
}
|
||||
]
|
||||
""");
|
||||
|
||||
private readonly List<AdvisorySourceConflictRecord> _conflicts =
|
||||
[
|
||||
new(
|
||||
ConflictId: Guid.Parse("49b08f4c-474e-4a88-9f71-b7f74572f9d3"),
|
||||
AdvisoryId: "CVE-2026-1188",
|
||||
PairedSourceKey: "ghsa",
|
||||
ConflictType: "severity_mismatch",
|
||||
Severity: "high",
|
||||
Status: "open",
|
||||
Description: "Severity mismatch between NVD and GHSA.",
|
||||
FirstDetectedAt: DateTimeOffset.Parse("2026-02-19T07:40:00Z"),
|
||||
LastDetectedAt: DateTimeOffset.Parse("2026-02-19T08:05:00Z"),
|
||||
ResolvedAt: null,
|
||||
DetailsJson: """{"lhs":"high","rhs":"critical"}"""),
|
||||
new(
|
||||
ConflictId: Guid.Parse("cb605891-90d5-4081-a17c-e55327ffce34"),
|
||||
AdvisoryId: "CVE-2026-2001",
|
||||
PairedSourceKey: "osv",
|
||||
ConflictType: "remediation_mismatch",
|
||||
Severity: "medium",
|
||||
Status: "resolved",
|
||||
Description: "Remediation mismatch resolved after triage.",
|
||||
FirstDetectedAt: DateTimeOffset.Parse("2026-02-18T11:00:00Z"),
|
||||
LastDetectedAt: DateTimeOffset.Parse("2026-02-18T13:15:00Z"),
|
||||
ResolvedAt: DateTimeOffset.Parse("2026-02-18T14:00:00Z"),
|
||||
DetailsJson: JsonSerializer.Serialize(new { resolution = "accepted_nvd", actor = "security-bot" }))
|
||||
];
|
||||
|
||||
public Task<AdvisorySourceImpactSnapshot> GetImpactAsync(
|
||||
string tenantId,
|
||||
string sourceKey,
|
||||
string? region,
|
||||
string? environment,
|
||||
string? sourceFamily,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.Equals(tenantId, "test-tenant", StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(sourceKey, _impact.SourceKey, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(new AdvisorySourceImpactSnapshot(
|
||||
SourceKey: sourceKey,
|
||||
SourceFamily: sourceFamily,
|
||||
Region: region,
|
||||
Environment: environment,
|
||||
ImpactedDecisionsCount: 0,
|
||||
ImpactSeverity: "none",
|
||||
LastDecisionAt: null,
|
||||
UpdatedAt: null,
|
||||
DecisionRefsJson: "[]"));
|
||||
}
|
||||
|
||||
return Task.FromResult(_impact with
|
||||
{
|
||||
Region = region ?? _impact.Region,
|
||||
Environment = environment ?? _impact.Environment,
|
||||
SourceFamily = sourceFamily ?? _impact.SourceFamily
|
||||
});
|
||||
}
|
||||
|
||||
public Task<AdvisorySourceConflictPage> ListConflictsAsync(
|
||||
string tenantId,
|
||||
string sourceKey,
|
||||
string? status,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.Equals(tenantId, "test-tenant", StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(sourceKey, _impact.SourceKey, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(new AdvisorySourceConflictPage(Array.Empty<AdvisorySourceConflictRecord>(), 0));
|
||||
}
|
||||
|
||||
var filtered = _conflicts
|
||||
.Where(item => string.IsNullOrWhiteSpace(status) || string.Equals(item.Status, status, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(static item => item.Severity switch
|
||||
{
|
||||
"critical" => 4,
|
||||
"high" => 3,
|
||||
"medium" => 2,
|
||||
"low" => 1,
|
||||
_ => 0
|
||||
})
|
||||
.ThenByDescending(static item => item.LastDetectedAt)
|
||||
.ThenBy(static item => item.ConflictId)
|
||||
.ToList();
|
||||
|
||||
var page = filtered
|
||||
.Skip(Math.Max(offset, 0))
|
||||
.Take(Math.Clamp(limit, 1, 200))
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(new AdvisorySourceConflictPage(page, filtered.Count));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user