up
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
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;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IExceptionRepository for offline/test runs.
|
||||
/// Provides minimal semantics needed for lifecycle processing.
|
||||
/// </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();
|
||||
|
||||
public Task<PolicyExceptionDocument> CreateExceptionAsync(PolicyExceptionDocument exception, CancellationToken cancellationToken)
|
||||
{
|
||||
_exceptions[(exception.TenantId.ToLowerInvariant(), exception.Id)] = Clone(exception);
|
||||
return Task.FromResult(exception);
|
||||
}
|
||||
|
||||
public Task<PolicyExceptionDocument?> GetExceptionAsync(string tenantId, string exceptionId, CancellationToken cancellationToken)
|
||||
{
|
||||
_exceptions.TryGetValue((tenantId.ToLowerInvariant(), exceptionId), out var value);
|
||||
return Task.FromResult(value is null ? null : Clone(value));
|
||||
}
|
||||
|
||||
public Task<PolicyExceptionDocument?> UpdateExceptionAsync(PolicyExceptionDocument exception, CancellationToken cancellationToken)
|
||||
{
|
||||
_exceptions[(exception.TenantId.ToLowerInvariant(), exception.Id)] = Clone(exception);
|
||||
return Task.FromResult<PolicyExceptionDocument?>(exception);
|
||||
}
|
||||
|
||||
public Task<ImmutableArray<PolicyExceptionDocument>> ListExceptionsAsync(ExceptionQueryOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _exceptions.Values.AsEnumerable();
|
||||
|
||||
if (options.Statuses.Any())
|
||||
{
|
||||
query = query.Where(e => options.Statuses.Contains(e.Status, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (options.Types.Any())
|
||||
{
|
||||
query = query.Where(e => options.Types.Contains(e.ExceptionType, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return Task.FromResult(query.Select(Clone).ToImmutableArray());
|
||||
}
|
||||
|
||||
public Task<ImmutableArray<PolicyExceptionDocument>> ListExceptionsAsync(string tenantId, ExceptionQueryOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenant = tenantId.ToLowerInvariant();
|
||||
var scoped = _exceptions.Values.Where(e => e.TenantId.Equals(tenant, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
var result = scoped.AsEnumerable();
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
public Task<ImmutableArray<PolicyExceptionDocument>> FindApplicableExceptionsAsync(string tenantId, ExceptionQueryOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
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();
|
||||
|
||||
return Task.FromResult(results);
|
||||
}
|
||||
|
||||
public Task<bool> UpdateExceptionStatusAsync(string tenantId, string exceptionId, string newStatus, DateTimeOffset timestamp, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = (tenantId.ToLowerInvariant(), exceptionId);
|
||||
if (!_exceptions.TryGetValue(key, out var existing))
|
||||
{
|
||||
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;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<bool> RevokeExceptionAsync(string tenantId, string exceptionId, string revokedBy, string? reason, DateTimeOffset timestamp, CancellationToken cancellationToken)
|
||||
{
|
||||
return UpdateExceptionStatusAsync(tenantId, exceptionId, "revoked", timestamp, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<ImmutableArray<PolicyExceptionDocument>> GetExpiringExceptionsAsync(string tenantId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken)
|
||||
{
|
||||
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)
|
||||
{
|
||||
_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;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public Task<ImmutableArray<PolicyExceptionDocument>> FindApplicableExceptionsAsync(string tenantId, string assetId, string? advisoryId, DateTimeOffset asOf, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenant = tenantId.ToLowerInvariant();
|
||||
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
var result = matchingIds
|
||||
.Where(activeExceptions.ContainsKey)
|
||||
.Select(id => activeExceptions[id])
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static PolicyExceptionDocument Clone(PolicyExceptionDocument source)
|
||||
{
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
private static ExceptionBindingDocument Clone(ExceptionBindingDocument source)
|
||||
{
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -33,13 +33,20 @@ internal interface IExceptionRepository
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists exceptions with filtering and pagination.
|
||||
/// Lists exceptions for a tenant with filtering and pagination.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<PolicyExceptionDocument>> ListExceptionsAsync(
|
||||
string tenantId,
|
||||
ExceptionQueryOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists exceptions across all tenants with filtering and pagination.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<PolicyExceptionDocument>> ListExceptionsAsync(
|
||||
ExceptionQueryOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds active exceptions that apply to a specific asset/advisory.
|
||||
/// </summary>
|
||||
|
||||
@@ -100,12 +100,50 @@ internal sealed class MongoExceptionRepository : IExceptionRepository
|
||||
string tenantId,
|
||||
ExceptionQueryOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = BuildFilter(options, tenantId.ToLowerInvariant());
|
||||
var sort = BuildSort(options);
|
||||
|
||||
var results = await Exceptions
|
||||
.Find(filter)
|
||||
.Sort(sort)
|
||||
.Skip(options.Skip)
|
||||
.Limit(options.Limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<PolicyExceptionDocument>> ListExceptionsAsync(
|
||||
ExceptionQueryOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = BuildFilter(options, tenantId: null);
|
||||
var sort = BuildSort(options);
|
||||
|
||||
var results = await Exceptions
|
||||
.Find(filter)
|
||||
.Sort(sort)
|
||||
.Skip(options.Skip)
|
||||
.Limit(options.Limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static FilterDefinition<PolicyExceptionDocument> BuildFilter(
|
||||
ExceptionQueryOptions options,
|
||||
string? tenantId)
|
||||
{
|
||||
var filterBuilder = Builders<PolicyExceptionDocument>.Filter;
|
||||
var filters = new List<FilterDefinition<PolicyExceptionDocument>>
|
||||
var filters = new List<FilterDefinition<PolicyExceptionDocument>>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
filterBuilder.Eq(e => e.TenantId, tenantId.ToLowerInvariant())
|
||||
};
|
||||
filters.Add(filterBuilder.Eq(e => e.TenantId, tenantId));
|
||||
}
|
||||
|
||||
if (options.Statuses.Length > 0)
|
||||
{
|
||||
@@ -135,21 +173,19 @@ internal sealed class MongoExceptionRepository : IExceptionRepository
|
||||
filterBuilder.Gt(e => e.ExpiresAt, now)));
|
||||
}
|
||||
|
||||
var filter = filterBuilder.And(filters);
|
||||
if (filters.Count == 0)
|
||||
{
|
||||
return FilterDefinition<PolicyExceptionDocument>.Empty;
|
||||
}
|
||||
|
||||
var sort = options.SortDirection.Equals("asc", StringComparison.OrdinalIgnoreCase)
|
||||
return filterBuilder.And(filters);
|
||||
}
|
||||
|
||||
private static SortDefinition<PolicyExceptionDocument> BuildSort(ExceptionQueryOptions options)
|
||||
{
|
||||
return options.SortDirection.Equals("asc", StringComparison.OrdinalIgnoreCase)
|
||||
? Builders<PolicyExceptionDocument>.Sort.Ascending(options.SortBy)
|
||||
: Builders<PolicyExceptionDocument>.Sort.Descending(options.SortBy);
|
||||
|
||||
var results = await Exceptions
|
||||
.Find(filter)
|
||||
.Sort(sort)
|
||||
.Skip(options.Skip)
|
||||
.Limit(options.Limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<PolicyExceptionDocument>> FindApplicableExceptionsAsync(
|
||||
|
||||
Reference in New Issue
Block a user