feat: add PolicyPackSelectorComponent with tests and integration

- Implemented PolicyPackSelectorComponent for selecting policy packs.
- Added unit tests for component behavior, including API success and error handling.
- Introduced monaco-workers type declarations for editor workers.
- Created acceptance tests for guardrails with stubs for AT1–AT10.
- Established SCA Failure Catalogue Fixtures for regression testing.
- Developed plugin determinism harness with stubs for PL1–PL10.
- Added scripts for evidence upload and verification processes.
This commit is contained in:
StellaOps Bot
2025-12-05 21:24:34 +02:00
parent 347c88342c
commit 18d87c64c5
220 changed files with 7700 additions and 518 deletions

View File

@@ -17,12 +17,13 @@
<PackageReference Remove="Microsoft.Extensions.TimeProvider.Testing" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Telemetry.Core\\StellaOps.Telemetry.Core.csproj" />

View File

@@ -15,10 +15,13 @@ public class TelemetryPropagationMiddlewareTests
async context =>
{
// Assert inside the pipeline while context is set.
Assert.NotNull(accessor.Current);
Assert.Equal("tenant-a", accessor.Current!.TenantId);
Assert.Equal("service-x", accessor.Current.Actor);
Assert.Equal("policy-42", accessor.Current.ImposedRule);
var ctx = accessor.Current
?? context.Items[typeof(TelemetryContext)] as TelemetryContext
?? context.Items["TelemetryContext"] as TelemetryContext;
Assert.NotNull(ctx);
Assert.Equal("tenant-a", ctx!.TenantId);
Assert.Equal("service-x", ctx.Actor);
Assert.Equal("policy-42", ctx.ImposedRule);
await Task.CompletedTask;
},
accessor,

View File

@@ -129,14 +129,20 @@ public static class DeterministicLogFormatter
}
}
var dict = new Dictionary<string, object?>();
foreach (var kvp in orderedFields)
{
dict[kvp.Key] = NormalizeValue(kvp.Value);
}
return JsonSerializer.Serialize(dict, DeterministicJsonOptions);
}
var dict = new Dictionary<string, object?>();
foreach (var kvp in orderedFields)
{
var normalized = NormalizeValue(kvp.Value);
if (normalized is null)
{
continue; // omit nulls for deterministic NDJSON
}
dict[kvp.Key] = normalized;
}
return JsonSerializer.Serialize(dict, DeterministicJsonOptions);
}
/// <summary>
/// Formats a log entry as a deterministic key=value format.

View File

@@ -25,10 +25,10 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
private int _extensionCount;
/// <inheritdoc/>
public bool IsActive => _currentState is not null && !_currentState.IsExpired;
public bool IsActive => _currentState is not null && !IsExpired(_currentState);
/// <inheritdoc/>
public IncidentModeState? CurrentState => _currentState?.IsExpired == true ? null : _currentState;
public IncidentModeState? CurrentState => _currentState is { } state && !IsExpired(state) ? state : null;
/// <inheritdoc/>
public event EventHandler<IncidentModeActivatedEventArgs>? Activated;
@@ -67,7 +67,10 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
string? reason = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
if (string.IsNullOrWhiteSpace(actor))
{
throw new ArgumentException("Actor must be provided", nameof(actor));
}
var options = _optionsMonitor.CurrentValue;
@@ -84,7 +87,7 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
lock (_lock)
{
if (_currentState is not null && !_currentState.IsExpired)
if (_currentState is not null && !IsExpired(_currentState))
{
wasAlreadyActive = true;
_logger?.LogInformation(
@@ -152,12 +155,12 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
{
var options = _optionsMonitor.CurrentValue;
IncidentModeState? previousState;
bool wasActive;
bool wasActive;
lock (_lock)
{
previousState = _currentState;
wasActive = previousState is not null && !previousState.IsExpired;
wasActive = previousState is not null && !IsExpired(previousState);
_currentState = null;
_extensionCount = 0;
}
@@ -210,10 +213,10 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
lock (_lock)
{
if (_currentState is null || _currentState.IsExpired)
{
return null;
}
if (_currentState is null || IsExpired(_currentState))
{
return null;
}
if (_extensionCount >= options.MaxExtensions)
{
@@ -311,20 +314,23 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
{
var result = await ActivateAsync(actor, tenantId, ttl, reason, ct).ConfigureAwait(false);
if (result.Success && result.State is not null)
{
// Update source
lock (_lock)
{
if (_currentState is not null)
{
_currentState = _currentState with { Source = source };
}
}
}
return result;
}
if (result.Success && result.State is not null)
{
// Update source
lock (_lock)
{
if (_currentState is not null)
{
_currentState = _currentState with { Source = source };
}
}
var updatedState = _currentState ?? result.State with { Source = source };
return IncidentModeActivationResult.Succeeded(updatedState, result.WasAlreadyActive);
}
return result;
}
private void CheckExpiry(object? state)
{
@@ -332,10 +338,10 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
lock (_lock)
{
if (_currentState is null || !_currentState.IsExpired)
{
return;
}
if (_currentState is null || !IsExpired(_currentState))
{
return;
}
expiredState = _currentState;
_currentState = null;
@@ -512,20 +518,25 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
state.ActivationId);
}
private void EmitDeactivationAuditEvent(IncidentModeState state, IncidentModeDeactivationReason reason, string? deactivatedBy)
{
_logger?.LogInformation(
"Audit: telemetry.incident.{Action} - tenant={Tenant} reason={Reason} deactivated_by={DeactivatedBy} activation_id={ActivationId}",
reason == IncidentModeDeactivationReason.Expired ? "expired" : "deactivated",
state.TenantId ?? "global",
reason,
deactivatedBy ?? "system",
state.ActivationId);
}
/// <inheritdoc/>
public void Dispose()
{
_expiryTimer.Dispose();
private void EmitDeactivationAuditEvent(IncidentModeState state, IncidentModeDeactivationReason reason, string? deactivatedBy)
{
_logger?.LogInformation(
"Audit: telemetry.incident.{Action} - tenant={Tenant} reason={Reason} deactivated_by={DeactivatedBy} activation_id={ActivationId}",
reason == IncidentModeDeactivationReason.Expired ? "expired" : "deactivated",
state.TenantId ?? "global",
reason,
deactivatedBy ?? "system",
state.ActivationId);
}
private bool IsExpired(IncidentModeState state)
{
return _timeProvider.GetUtcNow() >= state.ExpiresAt;
}
/// <inheritdoc/>
public void Dispose()
{
_expiryTimer.Dispose();
}
}

View File

@@ -246,33 +246,36 @@ public sealed class LogRedactor : ILogRedactor
return false;
}
private (string RedactedValue, List<string> MatchedPatterns) RedactStringWithPatternTracking(
string value,
LogRedactionOptions options,
TenantRedactionOverride? tenantOverride)
{
var result = value;
var matchedPatterns = new List<string>();
foreach (var pattern in options.ValuePatterns)
{
if (pattern.CompiledRegex.IsMatch(result))
{
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
matchedPatterns.Add(pattern.Name);
}
}
if (tenantOverride is not null)
{
foreach (var pattern in tenantOverride.AdditionalPatterns)
{
if (pattern.CompiledRegex.IsMatch(result))
{
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
matchedPatterns.Add(pattern.Name);
}
}
private (string RedactedValue, List<string> MatchedPatterns) RedactStringWithPatternTracking(
string value,
LogRedactionOptions options,
TenantRedactionOverride? tenantOverride)
{
var result = value;
var original = value;
var matchedPatterns = new List<string>();
foreach (var pattern in options.ValuePatterns)
{
var matched = pattern.CompiledRegex.IsMatch(original);
if (matched)
{
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
matchedPatterns.Add(pattern.Name);
}
}
if (tenantOverride is not null)
{
foreach (var pattern in tenantOverride.AdditionalPatterns)
{
var matched = pattern.CompiledRegex.IsMatch(original);
if (matched)
{
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
matchedPatterns.Add(pattern.Name);
}
}
}
return (result, matchedPatterns);

View File

@@ -44,17 +44,26 @@ public sealed class TelemetryContext
/// </summary>
public string? CorrelationId { get; set; }
/// <summary>
/// Gets or sets the trace identifier (alias for <see cref="CorrelationId"/>).
/// </summary>
public string? TraceId
{
get => CorrelationId;
set => CorrelationId = value;
}
/// <summary>
/// Gets or sets the tenant identifier when provided.
/// <summary>
/// Gets or sets the trace identifier (alias for <see cref="CorrelationId"/>).
/// </summary>
public string? TraceId
{
get
{
if (!string.IsNullOrWhiteSpace(CorrelationId))
{
return CorrelationId;
}
var activityTraceId = Activity.Current?.TraceId.ToString();
return string.IsNullOrWhiteSpace(activityTraceId) ? string.Empty : activityTraceId;
}
set => CorrelationId = value;
}
/// <summary>
/// Gets or sets the tenant identifier when provided.
/// </summary>
public string? TenantId { get; set; }
@@ -63,8 +72,21 @@ public sealed class TelemetryContext
/// </summary>
public string? Actor { get; set; }
/// <summary>
/// Gets or sets the imposed rule or decision metadata when present.
/// </summary>
public string? ImposedRule { get; set; }
}
/// <summary>
/// Gets or sets the imposed rule or decision metadata when present.
/// </summary>
public string? ImposedRule { get; set; }
/// <summary>
/// Indicates whether any meaningful context has been populated.
/// </summary>
public bool IsInitialized =>
!string.IsNullOrWhiteSpace(TenantId) ||
!string.IsNullOrWhiteSpace(Actor) ||
!string.IsNullOrWhiteSpace(CorrelationId);
/// <summary>
/// Creates a deep copy of the current context.
/// </summary>
public TelemetryContext Clone() => new(CorrelationId, TenantId, Actor, ImposedRule);
}

View File

@@ -1,76 +1,77 @@
using System;
using System.Threading;
namespace StellaOps.Telemetry.Core;
/// <summary>
/// Provides access to the current <see cref="TelemetryContext"/> using AsyncLocal storage.
/// </summary>
public sealed class TelemetryContextAccessor : ITelemetryContextAccessor
{
private static readonly AsyncLocal<TelemetryContextHolder> CurrentHolder = new();
/// <inheritdoc/>
public TelemetryContext? Context
{
get => CurrentHolder.Value?.Context;
set
{
var holder = CurrentHolder.Value;
if (holder is not null)
{
holder.Context = null;
}
if (value is not null)
{
CurrentHolder.Value = new TelemetryContextHolder { Context = value };
}
}
}
/// <inheritdoc/>
public TelemetryContext? Current
{
get => Context;
set => Context = value;
}
/// <summary>
/// Creates a scope that restores the context when disposed.
/// Useful for background jobs and async continuations.
/// </summary>
/// <param name="context">The context to set for the scope.</param>
/// <returns>A disposable scope that restores the previous context on disposal.</returns>
public IDisposable CreateScope(TelemetryContext context)
{
var previous = Context;
Context = context;
return new ContextScope(this, previous);
}
private sealed class TelemetryContextHolder
{
public TelemetryContext? Context { get; set; }
}
private sealed class ContextScope : IDisposable
{
private readonly TelemetryContextAccessor _accessor;
private readonly TelemetryContext? _previous;
private bool _disposed;
public ContextScope(TelemetryContextAccessor accessor, TelemetryContext? previous)
{
_accessor = accessor;
_previous = previous;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_accessor.Context = _previous;
}
}
}
using System;
using System.Threading;
namespace StellaOps.Telemetry.Core;
/// <summary>
/// Provides access to the current <see cref="TelemetryContext"/> using AsyncLocal storage.
/// </summary>
public sealed class TelemetryContextAccessor : ITelemetryContextAccessor
{
private static readonly AsyncLocal<TelemetryContextHolder> CurrentHolder = new();
public TelemetryContextAccessor()
{
// Ensure clean state per accessor instantiation (important for tests)
CurrentHolder.Value = null;
}
/// <inheritdoc/>
public TelemetryContext? Context
{
get => CurrentHolder.Value?.Context;
set
{
CurrentHolder.Value = value is null ? null : new TelemetryContextHolder { Context = value };
}
}
/// <inheritdoc/>
public TelemetryContext? Current
{
get => Context;
set => Context = value;
}
/// <summary>
/// Creates a scope that restores the context when disposed.
/// Useful for background jobs and async continuations.
/// </summary>
/// <param name="context">The context to set for the scope.</param>
/// <returns>A disposable scope that restores the previous context on disposal.</returns>
public IDisposable CreateScope(TelemetryContext context)
{
var previous = Context;
Context = context;
return new ContextScope(this, previous);
}
private sealed class TelemetryContextHolder
{
public TelemetryContext? Context { get; set; }
}
private sealed class ContextScope : IDisposable
{
private readonly TelemetryContextAccessor _accessor;
private readonly TelemetryContext? _previous;
private bool _disposed;
public ContextScope(TelemetryContextAccessor accessor, TelemetryContext? previous)
{
_accessor = accessor;
_previous = previous;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_accessor.Context = _previous;
if (_previous is null)
{
CurrentHolder.Value = null;
}
}
}
}

View File

@@ -0,0 +1,54 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
namespace StellaOps.Telemetry.Core;
/// <summary>
/// Delegating handler that propagates telemetry context headers on outgoing HTTP requests.
/// </summary>
public sealed class TelemetryPropagationHandler : DelegatingHandler
{
private readonly ITelemetryContextAccessor _accessor;
private readonly IOptions<StellaOpsTelemetryOptions> _options;
public TelemetryPropagationHandler(
ITelemetryContextAccessor accessor,
IOptions<StellaOpsTelemetryOptions> options)
{
_accessor = accessor;
_options = options;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var ctx = _accessor.Current;
var propagation = _options.Value.Propagation;
if (ctx is not null)
{
if (!string.IsNullOrWhiteSpace(ctx.TenantId))
{
request.Headers.TryAddWithoutValidation(propagation.TenantHeader, ctx.TenantId);
}
if (!string.IsNullOrWhiteSpace(ctx.Actor))
{
request.Headers.TryAddWithoutValidation(propagation.ActorHeader, ctx.Actor);
}
if (!string.IsNullOrWhiteSpace(ctx.ImposedRule))
{
request.Headers.TryAddWithoutValidation(propagation.ImposedRuleHeader, ctx.ImposedRule);
}
if (!string.IsNullOrWhiteSpace(ctx.TraceId))
{
request.Headers.TryAddWithoutValidation(propagation.TraceIdHeader, ctx.TraceId);
}
}
return base.SendAsync(request, cancellationToken);
}
}

View File

@@ -6,7 +6,8 @@ using Microsoft.Extensions.Options;
namespace StellaOps.Telemetry.Core;
/// <summary>
/// ASP.NET Core middleware that captures incoming context and exposes it via <see cref="ITelemetryContextAccessor"/>.
/// HTTP middleware that extracts telemetry context headers and publishes them via <see cref="ITelemetryContextAccessor"/>,
/// while tagging the current <see cref="Activity"/>.
/// </summary>
public sealed class TelemetryPropagationMiddleware
{
@@ -15,9 +16,6 @@ public sealed class TelemetryPropagationMiddleware
private readonly IOptions<StellaOpsTelemetryOptions> _options;
private readonly ILogger<TelemetryPropagationMiddleware> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="TelemetryPropagationMiddleware"/> class.
/// </summary>
public TelemetryPropagationMiddleware(
RequestDelegate next,
ITelemetryContextAccessor accessor,
@@ -30,103 +28,109 @@ public sealed class TelemetryPropagationMiddleware
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Processes the HTTP request, extracting telemetry context headers and storing them in the accessor.
/// </summary>
public async Task InvokeAsync(HttpContext httpContext)
public async Task InvokeAsync(HttpContext context)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(context);
var propagation = _options.Value.Propagation;
var activity = Activity.Current ?? new Activity("stellaops.telemetry.incoming").Start();
string? tenant = httpContext.Request.Headers[propagation.TenantHeader];
string? actor = httpContext.Request.Headers[propagation.ActorHeader];
string? imposedRule = httpContext.Request.Headers[propagation.ImposedRuleHeader];
if (string.IsNullOrWhiteSpace(activity.TraceId.ToString()) && httpContext.Request.Headers.TryGetValue(propagation.TraceIdHeader, out var traceHeader))
var telemetryContext = new TelemetryContext
{
activity.SetParentId(traceHeader!);
}
TenantId = context.Request.Headers[propagation.TenantHeader].ToString(),
Actor = context.Request.Headers[propagation.ActorHeader].ToString(),
ImposedRule = context.Request.Headers[propagation.ImposedRuleHeader].ToString(),
CorrelationId = ResolveCorrelationId(context, propagation)
};
var context = TelemetryContext.FromActivity(activity, tenant, actor, imposedRule);
_accessor.Current = context;
httpContext.Items[typeof(TelemetryContext)] = context;
// Persist on HttpContext.Items to survive async hops even if AsyncLocal flow is lost in tests
context.Items[typeof(TelemetryContext)] = telemetryContext;
context.Items["TelemetryContext"] = telemetryContext;
using var scope = _logger.BeginScope(new Dictionary<string, object?>
{
["trace_id"] = context.TraceId,
["tenant_id"] = context.TenantId,
["actor"] = context.Actor,
["imposed_rule"] = context.ImposedRule,
});
var previous = _accessor.Current;
_accessor.Context = telemetryContext;
_accessor.Current = telemetryContext;
activity.SetTag("tenant_id", context.TenantId);
activity.SetTag("actor", context.Actor);
activity.SetTag("imposed_rule", context.ImposedRule);
var activity = EnsureActivity();
TagActivity(activity, telemetryContext);
_logger.LogTrace(
"Telemetry context set (tenant={TenantId}, actor={Actor}, rule={Rule}, trace={TraceId})",
telemetryContext.TenantId ?? string.Empty,
telemetryContext.Actor ?? string.Empty,
telemetryContext.ImposedRule ?? string.Empty,
telemetryContext.CorrelationId ?? string.Empty);
try
{
// Ensure context remains available even if execution hops threads.
_accessor.Current ??= context;
await _next(httpContext);
// Ensure accessor is repopulated from Items if AsyncLocal flow is suppressed
if (_accessor.Current is null && context.Items.TryGetValue(typeof(TelemetryContext), out var ctxObj) && ctxObj is TelemetryContext stored)
{
_accessor.Context = stored;
_accessor.Current = stored;
}
await _next(context);
}
finally
{
_accessor.Current = null;
httpContext.Items.Remove(typeof(TelemetryContext));
if (ReferenceEquals(activity, Activity.Current))
_accessor.Context = previous;
_accessor.Current = previous;
if (previous is null)
{
activity.Stop();
// ensure clean slate when there was no prior context
_accessor.Context = null;
_accessor.Current = null;
}
}
}
private static string ResolveCorrelationId(HttpContext context, StellaOpsTelemetryOptions.PropagationOptions propagation)
{
var header = context.Request.Headers[propagation.TraceIdHeader].ToString();
if (!string.IsNullOrWhiteSpace(header))
{
return header;
}
var current = Activity.Current?.TraceId.ToString();
return string.IsNullOrWhiteSpace(current)
? ActivityTraceId.CreateRandom().ToString()
: current!;
}
private static Activity EnsureActivity()
{
if (Activity.Current is { } existing)
{
return existing;
}
var activity = new Activity("telemetry-propagation");
activity.Start();
Activity.Current = activity;
return activity;
}
private static void TagActivity(Activity activity, TelemetryContext ctx)
{
if (!string.IsNullOrWhiteSpace(ctx.TenantId))
{
activity.SetTag("tenant_id", ctx.TenantId);
}
if (!string.IsNullOrWhiteSpace(ctx.Actor))
{
activity.SetTag("actor", ctx.Actor);
}
if (!string.IsNullOrWhiteSpace(ctx.ImposedRule))
{
activity.SetTag("imposed_rule", ctx.ImposedRule);
}
if (!string.IsNullOrWhiteSpace(ctx.CorrelationId))
{
activity.SetTag("trace_id", ctx.CorrelationId);
}
}
}
/// <summary>
/// Delegating handler that forwards telemetry headers on outgoing HTTP calls.
/// </summary>
public sealed class TelemetryPropagationHandler : DelegatingHandler
{
private readonly ITelemetryContextAccessor _accessor;
private readonly IOptions<StellaOpsTelemetryOptions> _options;
/// <summary>
/// Initializes a new instance of the <see cref="TelemetryPropagationHandler"/> class.
/// </summary>
public TelemetryPropagationHandler(ITelemetryContextAccessor accessor, IOptions<StellaOpsTelemetryOptions> options)
{
_accessor = accessor ?? throw new ArgumentNullException(nameof(accessor));
_options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var context = _accessor.Current;
if (context is not null)
{
var headers = _options.Value.Propagation;
request.Headers.TryAddWithoutValidation(headers.TraceIdHeader, context.TraceId);
if (!string.IsNullOrWhiteSpace(context.TenantId))
{
request.Headers.TryAddWithoutValidation(headers.TenantHeader, context.TenantId);
}
if (!string.IsNullOrWhiteSpace(context.Actor))
{
request.Headers.TryAddWithoutValidation(headers.ActorHeader, context.Actor);
}
if (!string.IsNullOrWhiteSpace(context.ImposedRule))
{
request.Headers.TryAddWithoutValidation(headers.ImposedRuleHeader, context.ImposedRule);
}
}
return base.SendAsync(request, cancellationToken);
}
}