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
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:
@@ -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.
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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><= 0</c> to disable.
|
||||
/// </summary>
|
||||
public double MaxComplexityScore { get; set; } = 750d;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed compilation wall-clock duration in milliseconds. Set to <c><= 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><= 0</c> to disable.
|
||||
/// </summary>
|
||||
public double MaxComplexityScore { get; set; } = 750d;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed compilation wall-clock duration in milliseconds. Set to <c><= 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; }
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user