up
Some checks failed
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-12 09:35:37 +02:00
parent ce5ec9c158
commit efaf3cb789
238 changed files with 146274 additions and 5767 deletions

View File

@@ -5,7 +5,7 @@ Stand up the Policy Engine runtime host that evaluates organization policies aga
## Scope
- Minimal API host & background workers for policy runs (full, incremental, simulate).
- Mongo persistence for `policies`, `policy_runs`, and `effective_finding_*` collections.
- PostgreSQL persistence via `StellaOps.Policy.Storage.Postgres` for packs, runs, receipts, and overlays; in-memory fallbacks for dev/test.
- Change stream listeners and scheduler integration for incremental re-evaluation.
- Authority integration enforcing new `policy:*` and `effective:write` scopes.
- Observability: metrics, traces, structured logs, trace sampling.

View File

@@ -4,8 +4,9 @@ using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Storage.Mongo.Repositories;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.Policy.Storage.Postgres.Models;
using StellaOps.Policy.Storage.Postgres.Repositories;
using StackExchange.Redis;
namespace StellaOps.Policy.Engine.ExceptionCache;
@@ -347,73 +348,37 @@ internal sealed class RedisExceptionEffectiveCache : IExceptionEffectiveCache
try
{
// Get all active exceptions from repository
var exceptions = await _repository.ListExceptionsAsync(
var exceptions = await _repository.GetAllAsync(
tenantId,
new ExceptionQueryOptions
{
Statuses = ImmutableArray.Create("active"),
IncludeExpired = false,
Limit = _options.MaxEntriesPerTenant,
},
cancellationToken).ConfigureAwait(false);
ExceptionStatus.Active,
limit: _options.MaxEntriesPerTenant,
offset: 0,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (exceptions.Length == 0)
if (exceptions.Count == 0)
{
_logger.LogDebug("No active exceptions to warm for tenant {TenantId}", tenantId);
return;
}
// Get bindings for all exceptions
var entries = new List<ExceptionCacheEntry>();
foreach (var exception in exceptions)
{
var bindings = await _repository.GetBindingsForExceptionAsync(
tenantId, exception.Id, cancellationToken).ConfigureAwait(false);
foreach (var binding in bindings.Where(b => b.Status == "active"))
entries.Add(new ExceptionCacheEntry
{
entries.Add(new ExceptionCacheEntry
{
ExceptionId = exception.Id,
AssetId = binding.AssetId,
AdvisoryId = binding.AdvisoryId,
CveId = binding.CveId,
DecisionOverride = binding.DecisionOverride,
ExceptionType = exception.ExceptionType,
Priority = exception.Priority,
EffectiveFrom = binding.EffectiveFrom,
ExpiresAt = binding.ExpiresAt ?? exception.ExpiresAt,
CachedAt = now,
ExceptionName = exception.Name,
});
}
// Also add entries for scope-based exceptions without explicit bindings
if (exception.Scope.ApplyToAll || exception.Scope.AssetIds.Count > 0)
{
foreach (var assetId in exception.Scope.AssetIds)
{
foreach (var advisoryId in exception.Scope.AdvisoryIds.DefaultIfEmpty(null!))
{
entries.Add(new ExceptionCacheEntry
{
ExceptionId = exception.Id,
AssetId = assetId,
AdvisoryId = advisoryId,
CveId = null,
DecisionOverride = "allow",
ExceptionType = exception.ExceptionType,
Priority = exception.Priority,
EffectiveFrom = exception.EffectiveFrom ?? exception.CreatedAt,
ExpiresAt = exception.ExpiresAt,
CachedAt = now,
ExceptionName = exception.Name,
});
}
}
}
ExceptionId = exception.Id.ToString(),
AssetId = string.IsNullOrWhiteSpace(exception.ProjectId) ? "*" : exception.ProjectId!,
AdvisoryId = null,
CveId = null,
DecisionOverride = "allow",
ExceptionType = "waiver",
Priority = 0,
EffectiveFrom = exception.CreatedAt,
ExpiresAt = exception.ExpiresAt,
CachedAt = now,
ExceptionName = exception.Name,
});
}
if (entries.Count > 0)
@@ -430,7 +395,7 @@ internal sealed class RedisExceptionEffectiveCache : IExceptionEffectiveCache
_logger.LogInformation(
"Warmed cache with {Count} entries from {ExceptionCount} exceptions for tenant {TenantId} in {Duration}ms",
entries.Count, exceptions.Length, tenantId, sw.ElapsedMilliseconds);
entries.Count, exceptions.Count, tenantId, sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
@@ -584,7 +549,6 @@ internal sealed class RedisExceptionEffectiveCache : IExceptionEffectiveCache
switch (exceptionEvent.EventType.ToLowerInvariant())
{
case "activated":
// Warm the cache with the new exception
await WarmExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
break;
@@ -592,13 +556,11 @@ internal sealed class RedisExceptionEffectiveCache : IExceptionEffectiveCache
case "expired":
case "revoked":
case "deleted":
// Invalidate cache entries for this exception
await InvalidateExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
break;
case "updated":
// Invalidate and re-warm
await InvalidateExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
await WarmExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
@@ -606,14 +568,8 @@ internal sealed class RedisExceptionEffectiveCache : IExceptionEffectiveCache
break;
case "created":
// Only warm if already active
var exception = await _repository.GetExceptionAsync(
exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken).ConfigureAwait(false);
if (exception?.Status == "active")
{
await WarmExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
}
await WarmExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
break;
default:
@@ -626,10 +582,16 @@ internal sealed class RedisExceptionEffectiveCache : IExceptionEffectiveCache
private async Task WarmExceptionAsync(string tenantId, string exceptionId, CancellationToken cancellationToken)
{
var exception = await _repository.GetExceptionAsync(tenantId, exceptionId, cancellationToken)
if (!Guid.TryParse(exceptionId, out var exceptionGuid))
{
_logger.LogWarning("Unable to parse exception id {ExceptionId} for tenant {TenantId}", exceptionId, tenantId);
return;
}
var exception = await _repository.GetByIdAsync(tenantId, exceptionGuid, cancellationToken)
.ConfigureAwait(false);
if (exception is null || exception.Status != "active")
if (exception is null || exception.Status != ExceptionStatus.Active)
{
return;
}
@@ -637,31 +599,22 @@ internal sealed class RedisExceptionEffectiveCache : IExceptionEffectiveCache
var now = _timeProvider.GetUtcNow();
var entries = new List<ExceptionCacheEntry>();
var bindings = await _repository.GetBindingsForExceptionAsync(tenantId, exceptionId, cancellationToken)
.ConfigureAwait(false);
foreach (var binding in bindings.Where(b => b.Status == "active"))
entries.Add(new ExceptionCacheEntry
{
entries.Add(new ExceptionCacheEntry
{
ExceptionId = exception.Id,
AssetId = binding.AssetId,
AdvisoryId = binding.AdvisoryId,
CveId = binding.CveId,
DecisionOverride = binding.DecisionOverride,
ExceptionType = exception.ExceptionType,
Priority = exception.Priority,
EffectiveFrom = binding.EffectiveFrom,
ExpiresAt = binding.ExpiresAt ?? exception.ExpiresAt,
CachedAt = now,
ExceptionName = exception.Name,
});
}
ExceptionId = exception.Id.ToString(),
AssetId = string.IsNullOrWhiteSpace(exception.ProjectId) ? "*" : exception.ProjectId!,
AdvisoryId = null,
CveId = null,
DecisionOverride = "allow",
ExceptionType = "waiver",
Priority = 0,
EffectiveFrom = exception.CreatedAt,
ExpiresAt = exception.ExpiresAt,
CachedAt = now,
ExceptionName = exception.Name,
});
if (entries.Count > 0)
{
await SetBatchAsync(tenantId, entries, cancellationToken).ConfigureAwait(false);
}
await SetBatchAsync(tenantId, entries, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Warmed cache with {Count} entries for exception {ExceptionId}",

View File

@@ -1,29 +1,27 @@
using System.Collections.ObjectModel;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.Caching;
using StellaOps.Policy.Engine.EffectiveDecisionMap;
using StellaOps.Policy.Engine.ExceptionCache;
using StellaOps.Policy.Engine.ReachabilityFacts;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.Options;
/// <summary>
/// Root configuration for the Policy Engine host.
/// </summary>
public sealed class PolicyEngineOptions
{
public const string SectionName = "PolicyEngine";
public PolicyEngineAuthorityOptions Authority { get; } = new();
public PolicyEngineStorageOptions Storage { get; } = new();
public PolicyEngineWorkerOptions Workers { get; } = new();
public PolicyEngineResourceServerOptions ResourceServer { get; } = new();
public PolicyEngineCompilationOptions Compilation { get; } = new();
using System.Collections.ObjectModel;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.Caching;
using StellaOps.Policy.Engine.EffectiveDecisionMap;
using StellaOps.Policy.Engine.ExceptionCache;
using StellaOps.Policy.Engine.ReachabilityFacts;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.Options;
/// <summary>
/// Root configuration for the Policy Engine host.
/// </summary>
public sealed class PolicyEngineOptions
{
public const string SectionName = "PolicyEngine";
public PolicyEngineAuthorityOptions Authority { get; } = new();
public PolicyEngineWorkerOptions Workers { get; } = new();
public PolicyEngineResourceServerOptions ResourceServer { get; } = new();
public PolicyEngineCompilationOptions Compilation { get; } = new();
public PolicyEngineActivationOptions Activation { get; } = new();
@@ -42,11 +40,10 @@ public sealed class PolicyEngineOptions
public ExceptionCacheOptions ExceptionCache { get; } = new();
public PolicyEngineExceptionLifecycleOptions ExceptionLifecycle { get; } = new();
public void Validate()
{
public void Validate()
{
Authority.Validate();
Storage.Validate();
Workers.Validate();
ResourceServer.Validate();
Compilation.Validate();
@@ -57,196 +54,167 @@ public sealed class PolicyEngineOptions
ExceptionLifecycle.Validate();
}
}
public sealed class PolicyEngineAuthorityOptions
{
public bool Enabled { get; set; } = true;
public string Issuer { get; set; } = "https://authority.stella-ops.local";
public string ClientId { get; set; } = "policy-engine";
public string? ClientSecret { get; set; }
public IList<string> Scopes { get; } = new List<string>
{
StellaOpsScopes.PolicyRun,
StellaOpsScopes.FindingsRead,
StellaOpsScopes.EffectiveWrite
};
public int BackchannelTimeoutSeconds { get; set; } = 30;
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(Issuer))
{
throw new InvalidOperationException("Policy Engine authority configuration requires an issuer.");
}
if (!Uri.TryCreate(Issuer, UriKind.Absolute, out var issuerUri) || !issuerUri.IsAbsoluteUri)
{
throw new InvalidOperationException("Policy Engine authority issuer must be an absolute URI.");
}
if (issuerUri.Scheme != Uri.UriSchemeHttps && !issuerUri.IsLoopback)
{
throw new InvalidOperationException("Policy Engine authority issuer must use HTTPS unless targeting loopback.");
}
if (string.IsNullOrWhiteSpace(ClientId))
{
throw new InvalidOperationException("Policy Engine authority configuration requires a clientId.");
}
if (Scopes.Count == 0)
{
throw new InvalidOperationException("Policy Engine authority configuration requires at least one scope.");
}
if (BackchannelTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Policy Engine authority backchannel timeout must be greater than zero.");
}
}
}
public sealed class PolicyEngineStorageOptions
{
public string ConnectionString { get; set; } = "mongodb://localhost:27017/policy-engine";
public string DatabaseName { get; set; } = "policy_engine";
public int CommandTimeoutSeconds { get; set; } = 30;
public void Validate()
{
if (string.IsNullOrWhiteSpace(ConnectionString))
{
throw new InvalidOperationException("Policy Engine storage configuration requires a MongoDB connection string.");
}
if (string.IsNullOrWhiteSpace(DatabaseName))
{
throw new InvalidOperationException("Policy Engine storage configuration requires a database name.");
}
if (CommandTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Policy Engine storage command timeout must be greater than zero.");
}
}
public TimeSpan CommandTimeout => TimeSpan.FromSeconds(CommandTimeoutSeconds);
}
public sealed class PolicyEngineWorkerOptions
{
public int SchedulerIntervalSeconds { get; set; } = 15;
public int MaxConcurrentEvaluations { get; set; } = 4;
public void Validate()
{
if (SchedulerIntervalSeconds <= 0)
{
throw new InvalidOperationException("Policy Engine worker interval must be greater than zero.");
}
if (MaxConcurrentEvaluations <= 0)
{
throw new InvalidOperationException("Policy Engine worker concurrency must be greater than zero.");
}
}
}
public sealed class PolicyEngineResourceServerOptions
{
public string Authority { get; set; } = "https://authority.stella-ops.local";
public IList<string> Audiences { get; } = new List<string> { "api://policy-engine" };
public IList<string> RequiredScopes { get; } = new List<string> { StellaOpsScopes.PolicyRun };
public IList<string> RequiredTenants { get; } = new List<string>();
public IList<string> BypassNetworks { get; } = new List<string> { "127.0.0.1/32", "::1/128" };
public bool RequireHttpsMetadata { get; set; } = true;
public void Validate()
{
if (string.IsNullOrWhiteSpace(Authority))
{
throw new InvalidOperationException("Resource server configuration requires an Authority URL.");
}
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var uri))
{
throw new InvalidOperationException("Resource server Authority URL must be absolute.");
}
if (RequireHttpsMetadata && !uri.IsLoopback && !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Resource server Authority URL must use HTTPS when HTTPS metadata is required.");
}
}
}
public sealed class PolicyEngineCompilationOptions
{
/// <summary>
/// Maximum allowed complexity score for compiled policies. Set to <c>&lt;= 0</c> to disable.
/// </summary>
public double MaxComplexityScore { get; set; } = 750d;
/// <summary>
/// Maximum allowed compilation wall-clock duration in milliseconds. Set to <c>&lt;= 0</c> to disable.
/// </summary>
public int MaxDurationMilliseconds { get; set; } = 1500;
public bool EnforceComplexity => MaxComplexityScore > 0;
public bool EnforceDuration => MaxDurationMilliseconds > 0;
public void Validate()
{
if (MaxComplexityScore < 0)
{
throw new InvalidOperationException("Compilation.maxComplexityScore must be greater than or equal to zero.");
}
if (MaxDurationMilliseconds < 0)
{
throw new InvalidOperationException("Compilation.maxDurationMilliseconds must be greater than or equal to zero.");
}
}
}
public sealed class PolicyEngineAuthorityOptions
{
public bool Enabled { get; set; } = true;
public string Issuer { get; set; } = "https://authority.stella-ops.local";
public string ClientId { get; set; } = "policy-engine";
public string? ClientSecret { get; set; }
public IList<string> Scopes { get; } = new List<string>
{
StellaOpsScopes.PolicyRun,
StellaOpsScopes.FindingsRead,
StellaOpsScopes.EffectiveWrite
};
public int BackchannelTimeoutSeconds { get; set; } = 30;
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(Issuer))
{
throw new InvalidOperationException("Policy Engine authority configuration requires an issuer.");
}
if (!Uri.TryCreate(Issuer, UriKind.Absolute, out var issuerUri) || !issuerUri.IsAbsoluteUri)
{
throw new InvalidOperationException("Policy Engine authority issuer must be an absolute URI.");
}
if (issuerUri.Scheme != Uri.UriSchemeHttps && !issuerUri.IsLoopback)
{
throw new InvalidOperationException("Policy Engine authority issuer must use HTTPS unless targeting loopback.");
}
if (string.IsNullOrWhiteSpace(ClientId))
{
throw new InvalidOperationException("Policy Engine authority configuration requires a clientId.");
}
if (Scopes.Count == 0)
{
throw new InvalidOperationException("Policy Engine authority configuration requires at least one scope.");
}
if (BackchannelTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Policy Engine authority backchannel timeout must be greater than zero.");
}
}
}
public sealed class PolicyEngineWorkerOptions
{
public int SchedulerIntervalSeconds { get; set; } = 15;
public int MaxConcurrentEvaluations { get; set; } = 4;
public void Validate()
{
if (SchedulerIntervalSeconds <= 0)
{
throw new InvalidOperationException("Policy Engine worker interval must be greater than zero.");
}
if (MaxConcurrentEvaluations <= 0)
{
throw new InvalidOperationException("Policy Engine worker concurrency must be greater than zero.");
}
}
}
public sealed class PolicyEngineResourceServerOptions
{
public string Authority { get; set; } = "https://authority.stella-ops.local";
public IList<string> Audiences { get; } = new List<string> { "api://policy-engine" };
public IList<string> RequiredScopes { get; } = new List<string> { StellaOpsScopes.PolicyRun };
public IList<string> RequiredTenants { get; } = new List<string>();
public IList<string> BypassNetworks { get; } = new List<string> { "127.0.0.1/32", "::1/128" };
public bool RequireHttpsMetadata { get; set; } = true;
public void Validate()
{
if (string.IsNullOrWhiteSpace(Authority))
{
throw new InvalidOperationException("Resource server configuration requires an Authority URL.");
}
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var uri))
{
throw new InvalidOperationException("Resource server Authority URL must be absolute.");
}
if (RequireHttpsMetadata && !uri.IsLoopback && !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Resource server Authority URL must use HTTPS when HTTPS metadata is required.");
}
}
}
public sealed class PolicyEngineCompilationOptions
{
/// <summary>
/// Maximum allowed complexity score for compiled policies. Set to <c>&lt;= 0</c> to disable.
/// </summary>
public double MaxComplexityScore { get; set; } = 750d;
/// <summary>
/// Maximum allowed compilation wall-clock duration in milliseconds. Set to <c>&lt;= 0</c> to disable.
/// </summary>
public int MaxDurationMilliseconds { get; set; } = 1500;
public bool EnforceComplexity => MaxComplexityScore > 0;
public bool EnforceDuration => MaxDurationMilliseconds > 0;
public void Validate()
{
if (MaxComplexityScore < 0)
{
throw new InvalidOperationException("Compilation.maxComplexityScore must be greater than or equal to zero.");
}
if (MaxDurationMilliseconds < 0)
{
throw new InvalidOperationException("Compilation.maxDurationMilliseconds must be greater than or equal to zero.");
}
}
}
public sealed class PolicyEngineActivationOptions
{
/// <summary>
/// Forces two distinct approvals for every activation regardless of the request payload.
/// </summary>
public bool ForceTwoPersonApproval { get; set; } = false;
/// <summary>
/// Default value applied when callers omit <c>requiresTwoPersonApproval</c>.
/// </summary>
public bool DefaultRequiresTwoPersonApproval { get; set; } = false;
/// <summary>
/// Emits structured audit logs for every activation attempt.
/// </summary>
public bool EmitAuditLogs { get; set; } = true;
public void Validate()
/// <summary>
/// Forces two distinct approvals for every activation regardless of the request payload.
/// </summary>
public bool ForceTwoPersonApproval { get; set; } = false;
/// <summary>
/// Default value applied when callers omit <c>requiresTwoPersonApproval</c>.
/// </summary>
public bool DefaultRequiresTwoPersonApproval { get; set; } = false;
/// <summary>
/// Emits structured audit logs for every activation attempt.
/// </summary>
public bool EmitAuditLogs { get; set; } = true;
public void Validate()
{
}
}
@@ -319,128 +287,128 @@ public sealed class PolicyEngineEntropyOptions
public sealed class PolicyEngineRiskProfileOptions
{
/// <summary>
/// Enables risk profile integration for policy evaluation.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Default profile ID to use when no profile is specified.
/// </summary>
public string DefaultProfileId { get; set; } = "default";
/// <summary>
/// Directory containing risk profile JSON files.
/// </summary>
public string? ProfileDirectory { get; set; }
/// <summary>
/// Maximum inheritance depth for profile resolution.
/// </summary>
public int MaxInheritanceDepth { get; set; } = 10;
/// <summary>
/// Whether to validate profiles against the JSON schema on load.
/// </summary>
public bool ValidateOnLoad { get; set; } = true;
/// <summary>
/// Whether to cache resolved profiles in memory.
/// </summary>
public bool CacheResolvedProfiles { get; set; } = true;
/// <summary>
/// Inline profile definitions (for config-based profiles).
/// </summary>
public List<RiskProfileDefinition> Profiles { get; } = new();
public void Validate()
{
if (MaxInheritanceDepth <= 0)
{
throw new InvalidOperationException("RiskProfile.MaxInheritanceDepth must be greater than zero.");
}
if (string.IsNullOrWhiteSpace(DefaultProfileId))
{
throw new InvalidOperationException("RiskProfile.DefaultProfileId is required.");
}
}
}
/// <summary>
/// Inline risk profile definition in configuration.
/// </summary>
public sealed class RiskProfileDefinition
{
/// <summary>
/// Profile identifier.
/// </summary>
public required string Id { get; set; }
/// <summary>
/// Profile version (SemVer).
/// </summary>
public required string Version { get; set; }
/// <summary>
/// Human-readable description.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Parent profile ID for inheritance.
/// </summary>
public string? Extends { get; set; }
/// <summary>
/// Signal definitions for risk scoring.
/// </summary>
public List<RiskProfileSignalDefinition> Signals { get; } = new();
/// <summary>
/// Weight per signal name.
/// </summary>
public Dictionary<string, double> Weights { get; } = new();
/// <summary>
/// Optional metadata.
/// </summary>
public Dictionary<string, object?>? Metadata { get; set; }
}
/// <summary>
/// Inline signal definition in configuration.
/// </summary>
public sealed class RiskProfileSignalDefinition
{
/// <summary>
/// Signal name.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Signal source.
/// </summary>
public required string Source { get; set; }
/// <summary>
/// Signal type (boolean, numeric, categorical).
/// </summary>
public required string Type { get; set; }
/// <summary>
/// JSON Pointer path in evidence.
/// </summary>
public string? Path { get; set; }
/// <summary>
/// Optional transform expression.
/// </summary>
public string? Transform { get; set; }
/// <summary>
/// Optional unit for numeric signals.
/// </summary>
public string? Unit { get; set; }
}
/// <summary>
/// Enables risk profile integration for policy evaluation.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Default profile ID to use when no profile is specified.
/// </summary>
public string DefaultProfileId { get; set; } = "default";
/// <summary>
/// Directory containing risk profile JSON files.
/// </summary>
public string? ProfileDirectory { get; set; }
/// <summary>
/// Maximum inheritance depth for profile resolution.
/// </summary>
public int MaxInheritanceDepth { get; set; } = 10;
/// <summary>
/// Whether to validate profiles against the JSON schema on load.
/// </summary>
public bool ValidateOnLoad { get; set; } = true;
/// <summary>
/// Whether to cache resolved profiles in memory.
/// </summary>
public bool CacheResolvedProfiles { get; set; } = true;
/// <summary>
/// Inline profile definitions (for config-based profiles).
/// </summary>
public List<RiskProfileDefinition> Profiles { get; } = new();
public void Validate()
{
if (MaxInheritanceDepth <= 0)
{
throw new InvalidOperationException("RiskProfile.MaxInheritanceDepth must be greater than zero.");
}
if (string.IsNullOrWhiteSpace(DefaultProfileId))
{
throw new InvalidOperationException("RiskProfile.DefaultProfileId is required.");
}
}
}
/// <summary>
/// Inline risk profile definition in configuration.
/// </summary>
public sealed class RiskProfileDefinition
{
/// <summary>
/// Profile identifier.
/// </summary>
public required string Id { get; set; }
/// <summary>
/// Profile version (SemVer).
/// </summary>
public required string Version { get; set; }
/// <summary>
/// Human-readable description.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Parent profile ID for inheritance.
/// </summary>
public string? Extends { get; set; }
/// <summary>
/// Signal definitions for risk scoring.
/// </summary>
public List<RiskProfileSignalDefinition> Signals { get; } = new();
/// <summary>
/// Weight per signal name.
/// </summary>
public Dictionary<string, double> Weights { get; } = new();
/// <summary>
/// Optional metadata.
/// </summary>
public Dictionary<string, object?>? Metadata { get; set; }
}
/// <summary>
/// Inline signal definition in configuration.
/// </summary>
public sealed class RiskProfileSignalDefinition
{
/// <summary>
/// Signal name.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Signal source.
/// </summary>
public required string Source { get; set; }
/// <summary>
/// Signal type (boolean, numeric, categorical).
/// </summary>
public required string Type { get; set; }
/// <summary>
/// JSON Pointer path in evidence.
/// </summary>
public string? Path { get; set; }
/// <summary>
/// Optional transform expression.
/// </summary>
public string? Transform { get; set; }
/// <summary>
/// Optional unit for numeric signals.
/// </summary>
public string? Unit { get; set; }
}

View File

@@ -218,7 +218,6 @@ builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ViolationEventS
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.SeverityFusionService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ConflictHandlingService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyDecisionService>();
builder.Services.AddSingleton<IExceptionRepository, InMemoryExceptionRepository>();
builder.Services.AddSingleton<IReachabilityFactsStore, InMemoryReachabilityFactsStore>();
builder.Services.AddSingleton<IReachabilityFactsOverlayCache, InMemoryReachabilityFactsOverlayCache>();
builder.Services.AddSingleton<ReachabilityFactsJoiningService>();

View File

@@ -1,14 +1,14 @@
# Policy Engine Host Template
This service hosts the Policy Engine APIs and background workers introduced in **Policy Engine v2**. The project currently ships a minimal bootstrap that validates configuration, registers Authority clients, and exposes readiness/health endpoints. Future tasks will extend it with compilation, evaluation, and persistence features.
## Compliance Checklist
# Policy Engine Host Template
This service hosts the Policy Engine APIs and background workers introduced in **Policy Engine v2**. The project currently ships a minimal bootstrap that validates configuration, registers Authority clients, and exposes readiness/health endpoints. Future tasks will extend it with compilation, evaluation, and persistence features.
## Compliance Checklist
- [x] Configuration loads from `policy-engine.yaml`/environment variables and validates on startup.
- [x] Authority client scaffolding enforces `policy:*` + `effective:write` scopes and respects back-channel timeouts.
- [x] Resource server authentication requires Policy Engine scopes with tenant-aware policies.
- [x] Health and readiness endpoints exist for platform probes.
- [x] Deterministic policy evaluation pipeline implemented (POLICY-ENGINE-20-002).
- [x] Mongo materialisation writers implemented (POLICY-ENGINE-20-004).
- [x] PostgreSQL materialisation writers implemented (POLICY-ENGINE-20-004).
- [x] Observability (metrics/traces/logs) completed (POLICY-ENGINE-20-007).
- [x] Comprehensive test suites and perf baselines established (POLICY-ENGINE-20-008).

View File

@@ -160,7 +160,7 @@ public sealed class InMemoryReachabilityFactsStore : IReachabilityFactsStore
}
/// <summary>
/// Index definitions for MongoDB reachability_facts collection.
/// Index definitions for reachability_facts persistence (storage-agnostic hints).
/// </summary>
public static class ReachabilityFactsIndexes
{
@@ -180,7 +180,7 @@ public static class ReachabilityFactsIndexes
public const string ExpirationIndex = "expires_at_ttl";
/// <summary>
/// Gets the index definitions for creating MongoDB indexes.
/// Gets the index definitions for creating persistence indexes.
/// </summary>
public static IReadOnlyList<ReachabilityIndexDefinition> GetIndexDefinitions()
{
@@ -204,7 +204,7 @@ public static class ReachabilityFactsIndexes
}
/// <summary>
/// Index definition for MongoDB collection.
/// Index definition for reachability_facts collection.
/// </summary>
public sealed record ReachabilityIndexDefinition(
string Name,

View File

@@ -10,7 +10,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />

View File

@@ -1,8 +1,8 @@
using System.Collections.Immutable;
using System.Collections.Concurrent;
using System.Linq;
using StellaOps.Policy.Engine.Storage.Mongo.Documents;
using StellaOps.Policy.Engine.Storage.Mongo.Repositories;
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using StellaOps.Policy.Storage.Postgres.Models;
using StellaOps.Policy.Storage.Postgres.Repositories;
namespace StellaOps.Policy.Engine.Storage.InMemory;
@@ -12,340 +12,178 @@ namespace StellaOps.Policy.Engine.Storage.InMemory;
/// </summary>
public sealed class InMemoryExceptionRepository : IExceptionRepository
{
private readonly ConcurrentDictionary<(string Tenant, string Id), PolicyExceptionDocument> _exceptions = new();
private readonly ConcurrentDictionary<(string Tenant, string Id), ExceptionBindingDocument> _bindings = new();
private readonly ConcurrentDictionary<(string Tenant, Guid Id), ExceptionEntity> _exceptions = new();
public Task<PolicyExceptionDocument> CreateExceptionAsync(PolicyExceptionDocument exception, CancellationToken cancellationToken)
public Task<ExceptionEntity> CreateAsync(ExceptionEntity exception, CancellationToken cancellationToken = default)
{
_exceptions[(exception.TenantId.ToLowerInvariant(), exception.Id)] = Clone(exception);
return Task.FromResult(exception);
var id = exception.Id == Guid.Empty ? Guid.NewGuid() : exception.Id;
var stored = Copy(exception, id);
_exceptions[(Normalize(exception.TenantId), id)] = stored;
return Task.FromResult(stored);
}
public Task<PolicyExceptionDocument?> GetExceptionAsync(string tenantId, string exceptionId, CancellationToken cancellationToken)
public Task<ExceptionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
_exceptions.TryGetValue((tenantId.ToLowerInvariant(), exceptionId), out var value);
return Task.FromResult(value is null ? null : Clone(value));
_exceptions.TryGetValue((Normalize(tenantId), id), out var entity);
return Task.FromResult(entity is null ? null : Copy(entity));
}
public Task<PolicyExceptionDocument?> UpdateExceptionAsync(PolicyExceptionDocument exception, CancellationToken cancellationToken)
public Task<ExceptionEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
_exceptions[(exception.TenantId.ToLowerInvariant(), exception.Id)] = Clone(exception);
return Task.FromResult<PolicyExceptionDocument?>(exception);
var match = _exceptions
.Where(kvp => kvp.Key.Tenant == Normalize(tenantId) && kvp.Value.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
.Select(kvp => Copy(kvp.Value))
.FirstOrDefault();
return Task.FromResult(match);
}
public Task<ImmutableArray<PolicyExceptionDocument>> ListExceptionsAsync(ExceptionQueryOptions options, CancellationToken cancellationToken)
public Task<IReadOnlyList<ExceptionEntity>> GetAllAsync(
string tenantId,
ExceptionStatus? status = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
var query = _exceptions.Values.AsEnumerable();
var query = _exceptions
.Where(kvp => kvp.Key.Tenant == Normalize(tenantId))
.Select(kvp => kvp.Value);
if (options.Statuses.Any())
if (status.HasValue)
{
query = query.Where(e => options.Statuses.Contains(e.Status, StringComparer.OrdinalIgnoreCase));
query = query.Where(e => e.Status == status.Value);
}
if (options.Types.Any())
{
query = query.Where(e => options.Types.Contains(e.ExceptionType, StringComparer.OrdinalIgnoreCase));
}
var results = query
.Skip(offset)
.Take(limit)
.Select(x => Copy(x))
.ToList();
return Task.FromResult(query.Select(Clone).ToImmutableArray());
return Task.FromResult<IReadOnlyList<ExceptionEntity>>(results);
}
public Task<ImmutableArray<PolicyExceptionDocument>> ListExceptionsAsync(string tenantId, ExceptionQueryOptions options, CancellationToken cancellationToken)
public Task<IReadOnlyList<ExceptionEntity>> GetActiveForProjectAsync(
string tenantId,
string projectId,
CancellationToken cancellationToken = default)
{
var tenant = tenantId.ToLowerInvariant();
var scoped = _exceptions.Values.Where(e => e.TenantId.Equals(tenant, StringComparison.OrdinalIgnoreCase)).ToList();
var result = scoped.AsEnumerable();
var results = _exceptions
.Where(kvp => kvp.Key.Tenant == Normalize(tenantId))
.Select(kvp => kvp.Value)
.Where(e => e.Status == ExceptionStatus.Active)
.Where(e => string.IsNullOrWhiteSpace(e.ProjectId) || string.Equals(e.ProjectId, projectId, StringComparison.OrdinalIgnoreCase))
.Select(x => Copy(x))
.ToList();
if (options.Statuses.Any())
{
result = result.Where(e => options.Statuses.Contains(e.Status, StringComparer.OrdinalIgnoreCase));
}
if (options.Types.Any())
{
result = result.Where(e => options.Types.Contains(e.ExceptionType, StringComparer.OrdinalIgnoreCase));
}
return Task.FromResult(result.Select(Clone).ToImmutableArray());
return Task.FromResult<IReadOnlyList<ExceptionEntity>>(results);
}
public Task<ImmutableArray<PolicyExceptionDocument>> FindApplicableExceptionsAsync(string tenantId, ExceptionQueryOptions options, CancellationToken cancellationToken)
public Task<IReadOnlyList<ExceptionEntity>> GetActiveForRuleAsync(
string tenantId,
string ruleName,
CancellationToken cancellationToken = default)
{
var tenant = tenantId.ToLowerInvariant();
var results = _exceptions.Values
.Where(e => e.TenantId.Equals(tenant, StringComparison.OrdinalIgnoreCase))
.Where(e => e.Status.Equals("active", StringComparison.OrdinalIgnoreCase))
.Select(Clone)
.ToImmutableArray();
var results = _exceptions
.Where(kvp => kvp.Key.Tenant == Normalize(tenantId))
.Select(kvp => kvp.Value)
.Where(e => e.Status == ExceptionStatus.Active)
.Where(e =>
string.IsNullOrWhiteSpace(e.RulePattern) ||
Regex.IsMatch(ruleName, e.RulePattern, RegexOptions.IgnoreCase))
.Select(x => Copy(x))
.ToList();
return Task.FromResult(results);
return Task.FromResult<IReadOnlyList<ExceptionEntity>>(results);
}
public Task<bool> UpdateExceptionStatusAsync(string tenantId, string exceptionId, string newStatus, DateTimeOffset timestamp, CancellationToken cancellationToken)
public Task<bool> UpdateAsync(ExceptionEntity exception, CancellationToken cancellationToken = default)
{
var key = (tenantId.ToLowerInvariant(), exceptionId);
if (!_exceptions.TryGetValue(key, out var existing))
var key = (Normalize(exception.TenantId), exception.Id);
if (!_exceptions.ContainsKey(key))
{
return Task.FromResult(false);
}
var updated = Clone(existing);
updated.Status = newStatus;
updated.UpdatedAt = timestamp;
if (newStatus == "active")
{
updated.ActivatedAt = timestamp;
}
if (newStatus == "expired")
{
updated.RevokedAt = timestamp;
}
_exceptions[key] = updated;
_exceptions[key] = Copy(exception);
return Task.FromResult(true);
}
public Task<bool> RevokeExceptionAsync(string tenantId, string exceptionId, string revokedBy, string? reason, DateTimeOffset timestamp, CancellationToken cancellationToken)
public Task<bool> ApproveAsync(string tenantId, Guid id, string approvedBy, CancellationToken cancellationToken = default)
{
return UpdateExceptionStatusAsync(tenantId, exceptionId, "revoked", timestamp, cancellationToken);
// In-memory implementation treats approve as no-op since status enum has no pending state.
return Task.FromResult(true);
}
public Task<ImmutableArray<PolicyExceptionDocument>> GetExpiringExceptionsAsync(string tenantId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken)
public Task<bool> RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default)
{
var tenant = tenantId.ToLowerInvariant();
var results = _exceptions.Values
.Where(e => e.TenantId.Equals(tenant, StringComparison.OrdinalIgnoreCase))
.Where(e => e.Status.Equals("active", StringComparison.OrdinalIgnoreCase))
.Where(e => e.ExpiresAt is not null && e.ExpiresAt >= from && e.ExpiresAt <= to)
.Select(Clone)
.ToImmutableArray();
return Task.FromResult(results);
}
public Task<ImmutableArray<PolicyExceptionDocument>> GetPendingActivationsAsync(string tenantId, DateTimeOffset asOf, CancellationToken cancellationToken)
{
var tenant = tenantId.ToLowerInvariant();
var results = _exceptions.Values
.Where(e => e.TenantId.Equals(tenant, StringComparison.OrdinalIgnoreCase))
.Where(e => e.Status.Equals("approved", StringComparison.OrdinalIgnoreCase))
.Where(e => e.EffectiveFrom is null || e.EffectiveFrom <= asOf)
.Select(Clone)
.ToImmutableArray();
return Task.FromResult(results);
}
public Task<ExceptionReviewDocument> CreateReviewAsync(ExceptionReviewDocument review, CancellationToken cancellationToken)
{
return Task.FromResult(review);
}
public Task<ExceptionReviewDocument?> GetReviewAsync(string tenantId, string reviewId, CancellationToken cancellationToken)
{
return Task.FromResult<ExceptionReviewDocument?>(null);
}
public Task<ExceptionReviewDocument?> AddReviewDecisionAsync(string tenantId, string reviewId, ReviewDecisionDocument decision, CancellationToken cancellationToken)
{
return Task.FromResult<ExceptionReviewDocument?>(null);
}
public Task<ExceptionReviewDocument?> CompleteReviewAsync(string tenantId, string reviewId, string finalStatus, DateTimeOffset completedAt, CancellationToken cancellationToken)
{
return Task.FromResult<ExceptionReviewDocument?>(null);
}
public Task<ImmutableArray<ExceptionReviewDocument>> GetReviewsForExceptionAsync(string tenantId, string exceptionId, CancellationToken cancellationToken)
{
return Task.FromResult(ImmutableArray<ExceptionReviewDocument>.Empty);
}
public Task<ImmutableArray<ExceptionReviewDocument>> GetPendingReviewsAsync(string tenantId, string? reviewerId, CancellationToken cancellationToken)
{
return Task.FromResult(ImmutableArray<ExceptionReviewDocument>.Empty);
}
public Task<ExceptionBindingDocument> UpsertBindingAsync(ExceptionBindingDocument binding, CancellationToken cancellationToken)
{
_bindings[(binding.TenantId.ToLowerInvariant(), binding.Id)] = Clone(binding);
return Task.FromResult(binding);
}
public Task<ImmutableArray<ExceptionBindingDocument>> GetBindingsForExceptionAsync(string tenantId, string exceptionId, CancellationToken cancellationToken)
{
var tenant = tenantId.ToLowerInvariant();
var results = _bindings.Values
.Where(b => b.TenantId.Equals(tenant, StringComparison.OrdinalIgnoreCase) && b.ExceptionId == exceptionId)
.Select(Clone)
.ToImmutableArray();
return Task.FromResult(results);
}
public Task<ImmutableArray<ExceptionBindingDocument>> GetActiveBindingsForAssetAsync(string tenantId, string assetId, DateTimeOffset asOf, CancellationToken cancellationToken)
{
var tenant = tenantId.ToLowerInvariant();
var results = _bindings.Values
.Where(b => b.TenantId.Equals(tenant, StringComparison.OrdinalIgnoreCase))
.Where(b => b.AssetId == assetId)
.Where(b => b.Status == "active")
.Where(b => b.EffectiveFrom <= asOf && (b.ExpiresAt is null || b.ExpiresAt > asOf))
.Select(Clone)
.ToImmutableArray();
return Task.FromResult(results);
}
public Task<long> DeleteBindingsForExceptionAsync(string tenantId, string exceptionId, CancellationToken cancellationToken)
{
var tenant = tenantId.ToLowerInvariant();
var removed = _bindings.Where(kvp => kvp.Key.Tenant == tenant && kvp.Value.ExceptionId == exceptionId).ToList();
foreach (var kvp in removed)
var key = (Normalize(tenantId), id);
if (_exceptions.TryGetValue(key, out var existing))
{
_bindings.TryRemove(kvp.Key, out _);
}
return Task.FromResult((long)removed.Count);
}
public Task<ImmutableArray<ExceptionBindingDocument>> GetExpiredBindingsAsync(string tenantId, DateTimeOffset asOf, CancellationToken cancellationToken)
{
var tenant = tenantId.ToLowerInvariant();
var results = _bindings.Values
.Where(b => b.TenantId.Equals(tenant, StringComparison.OrdinalIgnoreCase))
.Where(b => b.Status == "active")
.Where(b => b.ExpiresAt is not null && b.ExpiresAt < asOf)
.Select(Clone)
.ToImmutableArray();
return Task.FromResult(results);
}
public Task<IReadOnlyDictionary<string, int>> GetExceptionCountsByStatusAsync(string tenantId, CancellationToken cancellationToken)
{
var tenant = tenantId.ToLowerInvariant();
var counts = _exceptions.Values
.Where(e => e.TenantId.Equals(tenant, StringComparison.OrdinalIgnoreCase))
.GroupBy(e => e.Status)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
return Task.FromResult((IReadOnlyDictionary<string, int>)counts);
}
public Task<ImmutableArray<ExceptionBindingDocument>> GetExpiredBindingsAsync(string tenantId, DateTimeOffset asOf, int limit, CancellationToken cancellationToken)
{
var tenant = tenantId.ToLowerInvariant();
var results = _bindings.Values
.Where(b => string.Equals(b.TenantId, tenant, StringComparison.OrdinalIgnoreCase))
.Where(b => b.Status == "active")
.Where(b => b.ExpiresAt is not null && b.ExpiresAt < asOf)
.Take(limit)
.Select(Clone)
.ToImmutableArray();
return Task.FromResult(results);
}
public Task<bool> UpdateBindingStatusAsync(string tenantId, string bindingId, string newStatus, CancellationToken cancellationToken)
{
var key = _bindings.Keys.FirstOrDefault(k => string.Equals(k.Tenant, tenantId, StringComparison.OrdinalIgnoreCase) && k.Id == bindingId);
if (key == default)
{
return Task.FromResult(false);
}
if (_bindings.TryGetValue(key, out var binding))
{
var updated = Clone(binding);
updated.Status = newStatus;
_bindings[key] = updated;
_exceptions[key] = Copy(
existing,
statusOverride: ExceptionStatus.Revoked,
revokedAtOverride: DateTimeOffset.UtcNow,
revokedByOverride: revokedBy);
return Task.FromResult(true);
}
return Task.FromResult(false);
}
public Task<ImmutableArray<PolicyExceptionDocument>> FindApplicableExceptionsAsync(string tenantId, string assetId, string? advisoryId, DateTimeOffset asOf, CancellationToken cancellationToken)
public Task<int> ExpireAsync(string tenantId, CancellationToken cancellationToken = default)
{
var tenant = tenantId.ToLowerInvariant();
var now = DateTimeOffset.UtcNow;
var normalizedTenant = Normalize(tenantId);
var expired = 0;
var activeExceptions = _exceptions.Values
.Where(e => string.Equals(e.TenantId, tenant, StringComparison.OrdinalIgnoreCase))
.Where(e => e.Status.Equals("active", StringComparison.OrdinalIgnoreCase))
.Where(e => (e.EffectiveFrom is null || e.EffectiveFrom <= asOf) && (e.ExpiresAt is null || e.ExpiresAt > asOf))
.ToDictionary(e => e.Id, Clone);
if (activeExceptions.Count == 0)
foreach (var kvp in _exceptions.Where(k => k.Key.Tenant == normalizedTenant))
{
return Task.FromResult(ImmutableArray<PolicyExceptionDocument>.Empty);
}
var matchingIds = _bindings.Values
.Where(b => string.Equals(b.TenantId, tenant, StringComparison.OrdinalIgnoreCase))
.Where(b => b.Status == "active")
.Where(b => b.EffectiveFrom <= asOf && (b.ExpiresAt is null || b.ExpiresAt > asOf))
.Where(b => b.AssetId == assetId)
.Where(b => advisoryId is null || string.IsNullOrEmpty(b.AdvisoryId) || b.AdvisoryId == advisoryId)
.Select(b => b.ExceptionId)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var ex in activeExceptions.Values)
{
if (ex.Scope.ApplyToAll)
if (kvp.Value.Status == ExceptionStatus.Active && kvp.Value.ExpiresAt is not null && kvp.Value.ExpiresAt <= now)
{
matchingIds.Add(ex.Id);
}
else if (ex.Scope.AssetIds.Contains(assetId, StringComparer.OrdinalIgnoreCase))
{
matchingIds.Add(ex.Id);
}
else if (advisoryId is not null && ex.Scope.AdvisoryIds.Contains(advisoryId, StringComparer.OrdinalIgnoreCase))
{
matchingIds.Add(ex.Id);
_exceptions[kvp.Key] = Copy(
kvp.Value,
statusOverride: ExceptionStatus.Expired,
revokedAtOverride: now);
expired++;
}
}
var result = matchingIds
.Where(activeExceptions.ContainsKey)
.Select(id => activeExceptions[id])
.ToImmutableArray();
return Task.FromResult(result);
return Task.FromResult(expired);
}
private static PolicyExceptionDocument Clone(PolicyExceptionDocument source)
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
return new PolicyExceptionDocument
{
Id = source.Id,
TenantId = source.TenantId,
Name = source.Name,
ExceptionType = source.ExceptionType,
Status = source.Status,
EffectiveFrom = source.EffectiveFrom,
ExpiresAt = source.ExpiresAt,
CreatedAt = source.CreatedAt,
UpdatedAt = source.UpdatedAt,
ActivatedAt = source.ActivatedAt,
RevokedAt = source.RevokedAt,
RevokedBy = source.RevokedBy,
RevocationReason = source.RevocationReason,
Scope = source.Scope,
RiskAssessment = source.RiskAssessment,
Tags = source.Tags,
};
var key = (Normalize(tenantId), id);
return Task.FromResult(_exceptions.TryRemove(key, out _));
}
private static ExceptionBindingDocument Clone(ExceptionBindingDocument source)
private static string Normalize(string value) => value.ToLowerInvariant();
private static ExceptionEntity Copy(
ExceptionEntity source,
Guid? idOverride = null,
ExceptionStatus? statusOverride = null,
DateTimeOffset? revokedAtOverride = null,
string? revokedByOverride = null) => new()
{
return new ExceptionBindingDocument
{
Id = source.Id,
TenantId = source.TenantId,
ExceptionId = source.ExceptionId,
AssetId = source.AssetId,
AdvisoryId = source.AdvisoryId,
Status = source.Status,
EffectiveFrom = source.EffectiveFrom,
ExpiresAt = source.ExpiresAt,
};
}
Id = idOverride ?? source.Id,
TenantId = source.TenantId,
Name = source.Name,
Description = source.Description,
RulePattern = source.RulePattern,
ResourcePattern = source.ResourcePattern,
ArtifactPattern = source.ArtifactPattern,
ProjectId = source.ProjectId,
Reason = source.Reason,
Status = statusOverride ?? source.Status,
ExpiresAt = source.ExpiresAt,
ApprovedBy = source.ApprovedBy,
ApprovedAt = source.ApprovedAt,
RevokedBy = revokedByOverride ?? source.RevokedBy,
RevokedAt = revokedAtOverride ?? source.RevokedAt,
Metadata = source.Metadata,
CreatedAt = source.CreatedAt,
CreatedBy = source.CreatedBy
};
}

View File

@@ -1,12 +1,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Policy.Engine.ExceptionCache;
using StellaOps.Policy.Engine.Events;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Storage.Mongo.Repositories;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.Policy.Storage.Postgres.Repositories;
namespace StellaOps.Policy.Engine.Workers;
@@ -17,111 +12,40 @@ namespace StellaOps.Policy.Engine.Workers;
internal sealed class ExceptionLifecycleService
{
private readonly IExceptionRepository _repository;
private readonly IExceptionEventPublisher _publisher;
private readonly IOptions<PolicyEngineOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ExceptionLifecycleService> _logger;
public ExceptionLifecycleService(
IExceptionRepository repository,
IExceptionEventPublisher publisher,
IOptions<PolicyEngineOptions> options,
TimeProvider timeProvider,
ILogger<ExceptionLifecycleService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task ProcessOnceAsync(CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
var lifecycle = _options.Value.ExceptionLifecycle;
var tenants = _options.Value.ResourceServer.RequiredTenants;
var pendingActivations = await _repository
.ListExceptionsAsync(new ExceptionQueryOptions
{
Statuses = ImmutableArray.Create("approved"),
}, cancellationToken)
.ConfigureAwait(false);
pendingActivations = pendingActivations
.Where(ex => ex.EffectiveFrom is null || ex.EffectiveFrom <= now)
.Take(lifecycle.MaxBatchSize)
.ToImmutableArray();
foreach (var ex in pendingActivations)
if (tenants.Count == 0)
{
var activated = await _repository.UpdateExceptionStatusAsync(
ex.TenantId, ex.Id, "active", now, cancellationToken).ConfigureAwait(false);
if (!activated)
{
continue;
}
PolicyEngineTelemetry.RecordExceptionLifecycle(ex.TenantId, "activated");
await _publisher.PublishAsync(new ExceptionEvent
{
EventType = "activated",
TenantId = ex.TenantId,
ExceptionId = ex.Id,
ExceptionName = ex.Name,
ExceptionType = ex.ExceptionType,
OccurredAt = now,
}, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Activated exception {ExceptionId} for tenant {TenantId} (effective from {EffectiveFrom:o})",
ex.Id,
ex.TenantId,
ex.EffectiveFrom);
_logger.LogDebug("No tenants configured for exception lifecycle processing; skipping.");
return;
}
var expiryWindowStart = now - lifecycle.ExpiryLookback;
var expiryWindowEnd = now + lifecycle.ExpiryHorizon;
var expiring = await _repository
.ListExceptionsAsync(new ExceptionQueryOptions
{
Statuses = ImmutableArray.Create("active"),
}, cancellationToken)
.ConfigureAwait(false);
expiring = expiring
.Where(ex => ex.ExpiresAt is not null && ex.ExpiresAt >= expiryWindowStart && ex.ExpiresAt <= expiryWindowEnd)
.Take(lifecycle.MaxBatchSize)
.ToImmutableArray();
foreach (var ex in expiring)
foreach (var tenant in tenants)
{
var expired = await _repository.UpdateExceptionStatusAsync(
ex.TenantId, ex.Id, "expired", now, cancellationToken).ConfigureAwait(false);
var expired = await _repository.ExpireAsync(tenant, cancellationToken).ConfigureAwait(false);
if (!expired)
if (expired > 0)
{
continue;
_logger.LogInformation(
"Expired {ExpiredCount} exceptions for tenant {TenantId}",
expired,
tenant);
}
PolicyEngineTelemetry.RecordExceptionLifecycle(ex.TenantId, "expired");
await _publisher.PublishAsync(new ExceptionEvent
{
EventType = "expired",
TenantId = ex.TenantId,
ExceptionId = ex.Id,
ExceptionName = ex.Name,
ExceptionType = ex.ExceptionType,
OccurredAt = now,
}, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Expired exception {ExceptionId} for tenant {TenantId} at {ExpiresAt:o}",
ex.Id,
ex.TenantId,
ex.ExpiresAt);
}
}
}

View File

@@ -29,9 +29,9 @@ internal sealed class PolicyEngineBootstrapWorker : BackgroundService
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Policy Engine bootstrap worker started. Authority issuer: {AuthorityIssuer}. Database: {Database}.",
options.Authority.Issuer,
options.Storage.DatabaseName);
logger.LogInformation(
"Policy Engine bootstrap worker started. Authority issuer: {AuthorityIssuer}. Storage: PostgreSQL (configured via Postgres:Policy).",
options.Authority.Issuer);
if (options.RiskProfile.Enabled)
{