feat(notifier): postgres suppression admin runtime
Sprint SPRINT_20260416_008_Notify_truthful_suppression_admin_runtime. Postgres-backed suppression runtime services wired through the admin runtime extension registered in the durable storage bootstrap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,841 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using AuditEntity = StellaOps.Notify.Persistence.Postgres.Models.NotifyAuditEntity;
|
||||
using OperatorOverrideEntity = StellaOps.Notify.Persistence.Postgres.Models.OperatorOverrideEntity;
|
||||
using ThrottleConfigEntity = StellaOps.Notify.Persistence.Postgres.Models.ThrottleConfigEntity;
|
||||
using PostgresAuditRepository = StellaOps.Notify.Persistence.Postgres.Repositories.INotifyAuditRepository;
|
||||
using PostgresOperatorOverrideRepository = StellaOps.Notify.Persistence.Postgres.Repositories.IOperatorOverrideRepository;
|
||||
using PostgresThrottleConfigRepository = StellaOps.Notify.Persistence.Postgres.Repositories.IThrottleConfigRepository;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
public sealed class PostgresSuppressionAuditLogger : ISuppressionAuditLogger
|
||||
{
|
||||
private readonly PostgresAuditRepository _auditRepository;
|
||||
private readonly ILogger<PostgresSuppressionAuditLogger> _logger;
|
||||
|
||||
public PostgresSuppressionAuditLogger(
|
||||
PostgresAuditRepository auditRepository,
|
||||
ILogger<PostgresSuppressionAuditLogger> logger)
|
||||
{
|
||||
_auditRepository = auditRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task LogAsync(SuppressionAuditEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
var audit = new AuditEntity
|
||||
{
|
||||
TenantId = entry.TenantId,
|
||||
Action = MapAction(entry.Action),
|
||||
ResourceType = entry.ResourceType,
|
||||
ResourceId = entry.ResourceId,
|
||||
Details = SerializeDetails(entry),
|
||||
CorrelationId = entry.CorrelationId,
|
||||
CreatedAt = entry.Timestamp
|
||||
};
|
||||
|
||||
await _auditRepository.CreateAsync(audit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Suppression audit persisted for tenant {TenantId}: {Action} {ResourceType}/{ResourceId}.",
|
||||
entry.TenantId,
|
||||
entry.Action,
|
||||
entry.ResourceType,
|
||||
entry.ResourceId);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SuppressionAuditEntry>> QueryAsync(
|
||||
SuppressionAuditQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var fetchLimit = Math.Clamp(query.Offset + query.Limit, query.Limit, 1000);
|
||||
var rows = await _auditRepository.ListAsync(query.TenantId, fetchLimit, 0, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var filtered = rows
|
||||
.Select(MapAudit)
|
||||
.Where(static entry => entry is not null)
|
||||
.Select(static entry => entry!)
|
||||
.Where(entry => !query.From.HasValue || entry.Timestamp >= query.From.Value)
|
||||
.Where(entry => !query.To.HasValue || entry.Timestamp <= query.To.Value)
|
||||
.Where(entry => query.Actions is null || query.Actions.Count == 0 || query.Actions.Contains(entry.Action))
|
||||
.Where(entry => string.IsNullOrWhiteSpace(query.Actor) || string.Equals(entry.Actor, query.Actor, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(entry => string.IsNullOrWhiteSpace(query.ResourceType) || string.Equals(entry.ResourceType, query.ResourceType, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(entry => string.IsNullOrWhiteSpace(query.ResourceId) || string.Equals(entry.ResourceId, query.ResourceId, StringComparison.OrdinalIgnoreCase))
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToList();
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private static string MapAction(SuppressionAuditAction action) => action switch
|
||||
{
|
||||
SuppressionAuditAction.CalendarCreated => "suppression.calendar.created",
|
||||
SuppressionAuditAction.CalendarUpdated => "suppression.calendar.updated",
|
||||
SuppressionAuditAction.CalendarDeleted => "suppression.calendar.deleted",
|
||||
SuppressionAuditAction.ThrottleConfigUpdated => "suppression.throttle.updated",
|
||||
SuppressionAuditAction.ThrottleConfigDeleted => "suppression.throttle.deleted",
|
||||
SuppressionAuditAction.MaintenanceWindowCreated => "suppression.maintenance.created",
|
||||
SuppressionAuditAction.MaintenanceWindowUpdated => "suppression.maintenance.updated",
|
||||
SuppressionAuditAction.MaintenanceWindowDeleted => "suppression.maintenance.deleted",
|
||||
SuppressionAuditAction.OverrideCreated => "suppression.override.created",
|
||||
SuppressionAuditAction.OverrideExpired => "suppression.override.expired",
|
||||
SuppressionAuditAction.OverrideRevoked => "suppression.override.revoked",
|
||||
SuppressionAuditAction.OverrideUsed => "suppression.override.used",
|
||||
_ => "suppression.unknown"
|
||||
};
|
||||
|
||||
private static SuppressionAuditAction? MapAction(string action) => action switch
|
||||
{
|
||||
"suppression.calendar.created" => SuppressionAuditAction.CalendarCreated,
|
||||
"suppression.calendar.updated" => SuppressionAuditAction.CalendarUpdated,
|
||||
"suppression.calendar.deleted" => SuppressionAuditAction.CalendarDeleted,
|
||||
"suppression.throttle.updated" => SuppressionAuditAction.ThrottleConfigUpdated,
|
||||
"suppression.throttle.deleted" => SuppressionAuditAction.ThrottleConfigDeleted,
|
||||
"suppression.maintenance.created" => SuppressionAuditAction.MaintenanceWindowCreated,
|
||||
"suppression.maintenance.updated" => SuppressionAuditAction.MaintenanceWindowUpdated,
|
||||
"suppression.maintenance.deleted" => SuppressionAuditAction.MaintenanceWindowDeleted,
|
||||
"suppression.override.created" => SuppressionAuditAction.OverrideCreated,
|
||||
"suppression.override.expired" => SuppressionAuditAction.OverrideExpired,
|
||||
"suppression.override.revoked" => SuppressionAuditAction.OverrideRevoked,
|
||||
"suppression.override.used" => SuppressionAuditAction.OverrideUsed,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static string SerializeDetails(SuppressionAuditEntry entry)
|
||||
{
|
||||
var details = new SortedDictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["clientIp"] = entry.ClientIp,
|
||||
["correlationId"] = entry.CorrelationId,
|
||||
["userAgent"] = entry.UserAgent
|
||||
};
|
||||
|
||||
if (entry.Details is not null)
|
||||
{
|
||||
foreach (var (key, value) in entry.Details.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
details[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(details);
|
||||
}
|
||||
|
||||
private static SuppressionAuditEntry? MapAudit(AuditEntity row)
|
||||
{
|
||||
var action = MapAction(row.Action);
|
||||
if (!action.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SuppressionAuditEntry
|
||||
{
|
||||
EntryId = row.Id.ToString(),
|
||||
TenantId = row.TenantId,
|
||||
Timestamp = row.CreatedAt,
|
||||
Actor = "unknown",
|
||||
Action = action.Value,
|
||||
ResourceType = row.ResourceType,
|
||||
ResourceId = row.ResourceId ?? string.Empty,
|
||||
Details = DeserializeDetails(row.Details),
|
||||
CorrelationId = row.CorrelationId
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object>? DeserializeDetails(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var details = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json);
|
||||
if (details is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return details.ToDictionary(pair => pair.Key, pair => (object)pair.Value, StringComparer.Ordinal);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PostgresThrottleConfigurationService : IThrottleConfigurationService
|
||||
{
|
||||
private const string DefaultConfigId = "default";
|
||||
|
||||
private readonly PostgresThrottleConfigRepository _repository;
|
||||
private readonly PostgresAuditRepository _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PostgresThrottleConfigurationService> _logger;
|
||||
|
||||
public PostgresThrottleConfigurationService(
|
||||
PostgresThrottleConfigRepository repository,
|
||||
PostgresAuditRepository auditRepository,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PostgresThrottleConfigurationService> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_auditRepository = auditRepository;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ThrottleConfiguration?> GetConfigurationAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _repository.GetDefaultAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return entity is null ? null : MapConfiguration(entity);
|
||||
}
|
||||
|
||||
public async Task<ThrottleConfiguration> UpsertConfigurationAsync(
|
||||
ThrottleConfiguration configuration,
|
||||
string? actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var existing = await _repository.GetDefaultAsync(configuration.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
var entity = BuildEntity(configuration, actor, now, existing);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
await _repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await AppendAuditAsync(
|
||||
configuration.TenantId,
|
||||
existing is null ? "throttle_config_created" : "throttle_config_updated",
|
||||
actor,
|
||||
new SortedDictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["burstWindowDurationSeconds"] = configuration.BurstWindowDuration.HasValue
|
||||
? (int)configuration.BurstWindowDuration.Value.TotalSeconds
|
||||
: null,
|
||||
["defaultDurationSeconds"] = (int)configuration.DefaultDuration.TotalSeconds,
|
||||
["enabled"] = configuration.Enabled,
|
||||
["eventKindOverrides"] = configuration.EventKindOverrides?.ToDictionary(
|
||||
pair => pair.Key,
|
||||
pair => (int)pair.Value.TotalSeconds,
|
||||
StringComparer.Ordinal),
|
||||
["maxEventsPerWindow"] = configuration.MaxEventsPerWindow
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Persisted throttle configuration for tenant {TenantId}.",
|
||||
configuration.TenantId);
|
||||
|
||||
return MapConfiguration(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteConfigurationAsync(
|
||||
string tenantId,
|
||||
string? actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _repository.DeleteAsync(tenantId, DefaultConfigId, cancellationToken).ConfigureAwait(false);
|
||||
if (!deleted)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await AppendAuditAsync(
|
||||
tenantId,
|
||||
"throttle_config_deleted",
|
||||
actor,
|
||||
null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<TimeSpan> GetEffectiveThrottleDurationAsync(
|
||||
string tenantId,
|
||||
string eventKind,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var configuration = await GetConfigurationAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (configuration is null || !configuration.Enabled)
|
||||
{
|
||||
return TimeSpan.FromMinutes(15);
|
||||
}
|
||||
|
||||
if (configuration.EventKindOverrides is { Count: > 0 })
|
||||
{
|
||||
var match = configuration.EventKindOverrides
|
||||
.Where(pair => eventKind.StartsWith(pair.Key, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(pair => pair.Key.Length)
|
||||
.Select(pair => (TimeSpan?)pair.Value)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (match.HasValue)
|
||||
{
|
||||
return match.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return configuration.DefaultDuration;
|
||||
}
|
||||
|
||||
private static ThrottleConfiguration MapConfiguration(ThrottleConfigEntity entity)
|
||||
{
|
||||
var metadata = DeserializeThrottleMetadata(entity.Metadata);
|
||||
var overrides = metadata.EventKindOverrides?.ToDictionary(
|
||||
pair => pair.Key,
|
||||
pair => TimeSpan.FromSeconds(pair.Value),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
return new ThrottleConfiguration
|
||||
{
|
||||
TenantId = entity.TenantId,
|
||||
DefaultDuration = entity.DefaultWindow,
|
||||
EventKindOverrides = overrides,
|
||||
MaxEventsPerWindow = entity.MaxNotificationsPerWindow,
|
||||
BurstWindowDuration = metadata.BurstWindowDurationSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(metadata.BurstWindowDurationSeconds.Value)
|
||||
: null,
|
||||
Enabled = entity.Enabled,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
CreatedBy = entity.CreatedBy,
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
UpdatedBy = entity.UpdatedBy
|
||||
};
|
||||
}
|
||||
|
||||
private static ThrottleConfigEntity BuildEntity(
|
||||
ThrottleConfiguration configuration,
|
||||
string? actor,
|
||||
DateTimeOffset now,
|
||||
ThrottleConfigEntity? existing)
|
||||
{
|
||||
var metadata = new ThrottleMetadataPayload(
|
||||
configuration.EventKindOverrides?.ToDictionary(
|
||||
pair => pair.Key,
|
||||
pair => (int)pair.Value.TotalSeconds,
|
||||
StringComparer.Ordinal),
|
||||
configuration.BurstWindowDuration.HasValue
|
||||
? (int)configuration.BurstWindowDuration.Value.TotalSeconds
|
||||
: null);
|
||||
|
||||
return new ThrottleConfigEntity
|
||||
{
|
||||
ConfigId = DefaultConfigId,
|
||||
TenantId = configuration.TenantId,
|
||||
Name = existing?.Name ?? "Tenant Default Throttle",
|
||||
DefaultWindow = configuration.DefaultDuration,
|
||||
MaxNotificationsPerWindow = configuration.MaxEventsPerWindow,
|
||||
ChannelId = null,
|
||||
IsDefault = true,
|
||||
Enabled = configuration.Enabled,
|
||||
Description = existing?.Description,
|
||||
Metadata = JsonSerializer.Serialize(metadata),
|
||||
CreatedBy = existing?.CreatedBy ?? actor,
|
||||
CreatedAt = existing?.CreatedAt ?? now,
|
||||
UpdatedBy = actor,
|
||||
UpdatedAt = now
|
||||
};
|
||||
}
|
||||
|
||||
private async Task AppendAuditAsync(
|
||||
string tenantId,
|
||||
string action,
|
||||
string? actor,
|
||||
object? details,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = new SortedDictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["actor"] = actor
|
||||
};
|
||||
|
||||
if (details is not null)
|
||||
{
|
||||
payload["details"] = details;
|
||||
}
|
||||
|
||||
await _auditRepository.CreateAsync(
|
||||
new AuditEntity
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Action = action,
|
||||
ResourceType = "throttle-config",
|
||||
ResourceId = DefaultConfigId,
|
||||
Details = JsonSerializer.Serialize(payload),
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static ThrottleMetadataPayload DeserializeThrottleMetadata(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return new ThrottleMetadataPayload(null, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ThrottleMetadataPayload>(json)
|
||||
?? new ThrottleMetadataPayload(null, null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new ThrottleMetadataPayload(null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record ThrottleMetadataPayload(
|
||||
IReadOnlyDictionary<string, int>? EventKindOverrides,
|
||||
int? BurstWindowDurationSeconds);
|
||||
}
|
||||
|
||||
public sealed class PostgresOperatorOverrideService : IOperatorOverrideService
|
||||
{
|
||||
private readonly PostgresOperatorOverrideRepository _repository;
|
||||
private readonly ISuppressionAuditLogger _auditLogger;
|
||||
private readonly OperatorOverrideOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PostgresOperatorOverrideService> _logger;
|
||||
|
||||
public PostgresOperatorOverrideService(
|
||||
PostgresOperatorOverrideRepository repository,
|
||||
ISuppressionAuditLogger auditLogger,
|
||||
IOptions<OperatorOverrideOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PostgresOperatorOverrideService> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_auditLogger = auditLogger;
|
||||
_options = options.Value;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<OperatorOverride> CreateOverrideAsync(
|
||||
string tenantId,
|
||||
OperatorOverrideCreate request,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (request.Duration > _options.MaxDuration)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Override duration ({request.Duration}) exceeds maximum allowed ({_options.MaxDuration}).",
|
||||
nameof(request));
|
||||
}
|
||||
|
||||
if (request.Duration < _options.MinDuration)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Override duration ({request.Duration}) is below minimum allowed ({_options.MinDuration}).",
|
||||
nameof(request));
|
||||
}
|
||||
|
||||
var activeCount = (await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false))
|
||||
.Select(MapOverride)
|
||||
.Count(existing => existing.Status == OverrideStatus.Active && existing.ExpiresAt > now);
|
||||
if (activeCount >= _options.MaxActiveOverridesPerTenant)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Maximum active overrides ({_options.MaxActiveOverridesPerTenant}) reached for tenant.");
|
||||
}
|
||||
|
||||
var effectiveFrom = request.EffectiveFrom ?? now;
|
||||
var created = new OperatorOverrideEntity
|
||||
{
|
||||
OverrideId = $"ovr-{Guid.NewGuid():N}"[..16],
|
||||
TenantId = tenantId,
|
||||
OverrideType = request.Type.ToString(),
|
||||
EffectiveFrom = effectiveFrom,
|
||||
ExpiresAt = effectiveFrom + request.Duration,
|
||||
Reason = request.Reason,
|
||||
EventKinds = SerializeStringList(request.EventKinds),
|
||||
CorrelationKeys = SerializeStringList(request.CorrelationKeys),
|
||||
MaxUsageCount = request.MaxUsageCount,
|
||||
UsageCount = 0,
|
||||
Status = "active",
|
||||
CreatedBy = actor,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await _repository.CreateAsync(created, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var model = MapOverride(created);
|
||||
|
||||
var createdDetails = new Dictionary<string, object>
|
||||
{
|
||||
["correlationKeys"] = model.CorrelationKeys,
|
||||
["eventKinds"] = model.EventKinds,
|
||||
["expiresAt"] = model.ExpiresAt,
|
||||
["type"] = model.Type.ToString()
|
||||
};
|
||||
|
||||
if (model.MaxUsageCount.HasValue)
|
||||
{
|
||||
createdDetails["maxUsageCount"] = model.MaxUsageCount.Value;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(model.Reason))
|
||||
{
|
||||
createdDetails["reason"] = model.Reason;
|
||||
}
|
||||
|
||||
await _auditLogger.LogAsync(
|
||||
new SuppressionAuditEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Timestamp = now,
|
||||
Actor = actor,
|
||||
Action = SuppressionAuditAction.OverrideCreated,
|
||||
ResourceType = "OperatorOverride",
|
||||
ResourceId = created.OverrideId,
|
||||
Details = createdDetails
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Persisted operator override {OverrideId} for tenant {TenantId}.",
|
||||
created.OverrideId,
|
||||
tenantId);
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
public async Task<OperatorOverride?> GetOverrideAsync(
|
||||
string tenantId,
|
||||
string overrideId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _repository.GetByIdAsync(tenantId, overrideId, cancellationToken).ConfigureAwait(false);
|
||||
return entity is null ? null : MapOverride(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OperatorOverride>> ListActiveOverridesAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var overrides = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return overrides
|
||||
.Select(MapOverride)
|
||||
.Where(model => model.Status == OverrideStatus.Active && model.EffectiveFrom <= now && model.ExpiresAt > now)
|
||||
.OrderBy(model => model.ExpiresAt)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> RevokeOverrideAsync(
|
||||
string tenantId,
|
||||
string overrideId,
|
||||
string actor,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _repository.GetByIdAsync(tenantId, overrideId, cancellationToken).ConfigureAwait(false);
|
||||
if (entity is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var current = MapOverride(entity);
|
||||
if (current.Status != OverrideStatus.Active)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = CopyEntity(
|
||||
entity,
|
||||
status: "revoked",
|
||||
revokedBy: actor,
|
||||
revokedAt: now,
|
||||
revocationReason: reason,
|
||||
updatedAt: now);
|
||||
|
||||
var saved = await _repository.UpdateAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
if (!saved)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var revokedDetails = new Dictionary<string, object>
|
||||
{
|
||||
["usageCount"] = current.UsageCount
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(current.Reason))
|
||||
{
|
||||
revokedDetails["originalReason"] = current.Reason;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
revokedDetails["reason"] = reason;
|
||||
}
|
||||
|
||||
await _auditLogger.LogAsync(
|
||||
new SuppressionAuditEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Timestamp = now,
|
||||
Actor = actor,
|
||||
Action = SuppressionAuditAction.OverrideRevoked,
|
||||
ResourceType = "OperatorOverride",
|
||||
ResourceId = overrideId,
|
||||
Details = revokedDetails
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<OverrideCheckResult> CheckOverrideAsync(
|
||||
string tenantId,
|
||||
string eventKind,
|
||||
string? correlationKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var applicable = (await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false))
|
||||
.Select(MapOverride)
|
||||
.Where(model => model.Status == OverrideStatus.Active)
|
||||
.Where(model => model.EffectiveFrom <= now && model.ExpiresAt > now)
|
||||
.Where(model => MatchesEventKind(model, eventKind))
|
||||
.Where(model => MatchesCorrelationKey(model, correlationKey))
|
||||
.Where(model => !model.MaxUsageCount.HasValue || model.UsageCount < model.MaxUsageCount)
|
||||
.OrderByDescending(model => model.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
return applicable is null
|
||||
? OverrideCheckResult.NoOverride()
|
||||
: OverrideCheckResult.WithOverride(applicable);
|
||||
}
|
||||
|
||||
public async Task RecordOverrideUsageAsync(
|
||||
string tenantId,
|
||||
string overrideId,
|
||||
string eventKind,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _repository.GetByIdAsync(tenantId, overrideId, cancellationToken).ConfigureAwait(false);
|
||||
if (entity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var model = MapOverride(entity);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var usageCount = model.UsageCount + 1;
|
||||
var updated = CopyEntity(
|
||||
entity,
|
||||
usageCount: usageCount,
|
||||
status: entity.MaxUsageCount.HasValue && usageCount >= entity.MaxUsageCount.Value ? "exhausted" : entity.Status,
|
||||
updatedAt: now);
|
||||
|
||||
if (!await _repository.UpdateAsync(updated, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var usageDetails = new Dictionary<string, object>
|
||||
{
|
||||
["eventKind"] = eventKind,
|
||||
["usageCount"] = usageCount,
|
||||
["status"] = updated.Status
|
||||
};
|
||||
|
||||
if (entity.MaxUsageCount.HasValue)
|
||||
{
|
||||
usageDetails["maxUsageCount"] = entity.MaxUsageCount.Value;
|
||||
}
|
||||
|
||||
await _auditLogger.LogAsync(
|
||||
new SuppressionAuditEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Timestamp = now,
|
||||
Actor = "system",
|
||||
Action = SuppressionAuditAction.OverrideUsed,
|
||||
ResourceType = "OperatorOverride",
|
||||
ResourceId = overrideId,
|
||||
Details = usageDetails
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private OperatorOverride MapOverride(OperatorOverrideEntity entity)
|
||||
{
|
||||
var usageCount = entity.UsageCount;
|
||||
var status = MapStatus(entity.Status);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (status == OverrideStatus.Active && entity.ExpiresAt <= now)
|
||||
{
|
||||
status = OverrideStatus.Expired;
|
||||
}
|
||||
else if (status == OverrideStatus.Active && entity.MaxUsageCount.HasValue && usageCount >= entity.MaxUsageCount.Value)
|
||||
{
|
||||
status = OverrideStatus.Exhausted;
|
||||
}
|
||||
|
||||
return new OperatorOverride
|
||||
{
|
||||
OverrideId = entity.OverrideId,
|
||||
TenantId = entity.TenantId,
|
||||
Type = ParseOverrideType(entity.OverrideType),
|
||||
Reason = entity.Reason ?? string.Empty,
|
||||
EffectiveFrom = entity.EffectiveFrom,
|
||||
ExpiresAt = entity.ExpiresAt,
|
||||
EventKinds = DeserializeStringList(entity.EventKinds),
|
||||
CorrelationKeys = DeserializeStringList(entity.CorrelationKeys),
|
||||
MaxUsageCount = entity.MaxUsageCount,
|
||||
UsageCount = usageCount,
|
||||
CreatedBy = entity.CreatedBy ?? "system",
|
||||
CreatedAt = entity.CreatedAt,
|
||||
Status = status,
|
||||
RevokedBy = entity.RevokedBy,
|
||||
RevokedAt = entity.RevokedAt,
|
||||
RevocationReason = entity.RevocationReason
|
||||
};
|
||||
}
|
||||
|
||||
private static OverrideType ParseOverrideType(string value)
|
||||
{
|
||||
return Enum.TryParse<OverrideType>(value, ignoreCase: true, out var parsed)
|
||||
? parsed
|
||||
: OverrideType.All;
|
||||
}
|
||||
|
||||
private static OverrideStatus MapStatus(string value)
|
||||
{
|
||||
return value.ToLowerInvariant() switch
|
||||
{
|
||||
"active" => OverrideStatus.Active,
|
||||
"expired" => OverrideStatus.Expired,
|
||||
"revoked" => OverrideStatus.Revoked,
|
||||
"exhausted" => OverrideStatus.Exhausted,
|
||||
_ => OverrideStatus.Active
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesEventKind(OperatorOverride model, string eventKind)
|
||||
{
|
||||
return model.EventKinds.Count == 0
|
||||
|| model.EventKinds.Any(kind => kind == "*" || eventKind.StartsWith(kind, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool MatchesCorrelationKey(OperatorOverride model, string? correlationKey)
|
||||
{
|
||||
if (model.CorrelationKeys.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(correlationKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return model.CorrelationKeys.Contains(correlationKey, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string SerializeStringList(IReadOnlyList<string>? values)
|
||||
{
|
||||
var normalized = NormalizeList(values);
|
||||
return JsonSerializer.Serialize(normalized);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> DeserializeStringList(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return NormalizeList(JsonSerializer.Deserialize<List<string>>(json));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeList(IEnumerable<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static OperatorOverrideEntity CopyEntity(
|
||||
OperatorOverrideEntity source,
|
||||
string? status = null,
|
||||
string? revokedBy = null,
|
||||
DateTimeOffset? revokedAt = null,
|
||||
string? revocationReason = null,
|
||||
int? usageCount = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new OperatorOverrideEntity
|
||||
{
|
||||
OverrideId = source.OverrideId,
|
||||
TenantId = source.TenantId,
|
||||
OverrideType = source.OverrideType,
|
||||
EffectiveFrom = source.EffectiveFrom,
|
||||
ExpiresAt = source.ExpiresAt,
|
||||
ChannelId = source.ChannelId,
|
||||
RuleId = source.RuleId,
|
||||
Reason = source.Reason,
|
||||
EventKinds = source.EventKinds,
|
||||
CorrelationKeys = source.CorrelationKeys,
|
||||
MaxUsageCount = source.MaxUsageCount,
|
||||
UsageCount = usageCount ?? source.UsageCount,
|
||||
Status = status ?? source.Status,
|
||||
RevokedBy = revokedBy ?? source.RevokedBy,
|
||||
RevokedAt = revokedAt ?? source.RevokedAt,
|
||||
RevocationReason = revocationReason ?? source.RevocationReason,
|
||||
CreatedBy = source.CreatedBy,
|
||||
CreatedAt = source.CreatedAt,
|
||||
UpdatedAt = updatedAt ?? source.UpdatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user