Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly. - Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps. - Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges. - Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges. - Set up project file for the test project with necessary dependencies and configurations. - Include JSON fixture files for testing purposes.
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notifier.Tests.Support;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
using StellaOps.Notifier.Worker.Processing;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
using Xunit;
|
||||
using System;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notifier.Tests.Support;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
using StellaOps.Notifier.Worker.Processing;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests;
|
||||
|
||||
@@ -15,23 +17,27 @@ public sealed class EventProcessorTests
|
||||
[Fact]
|
||||
public async Task ProcessAsync_MatchesRule_StoresSingleDeliveryWithIdempotency()
|
||||
{
|
||||
var ruleRepository = new InMemoryRuleRepository();
|
||||
var deliveryRepository = new InMemoryDeliveryRepository();
|
||||
var lockRepository = new InMemoryLockRepository();
|
||||
var evaluator = new DefaultNotifyRuleEvaluator();
|
||||
var options = Options.Create(new NotifierWorkerOptions
|
||||
{
|
||||
DefaultIdempotencyTtl = TimeSpan.FromMinutes(5)
|
||||
});
|
||||
|
||||
var processor = new NotifierEventProcessor(
|
||||
ruleRepository,
|
||||
deliveryRepository,
|
||||
lockRepository,
|
||||
evaluator,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<NotifierEventProcessor>.Instance);
|
||||
var ruleRepository = new InMemoryRuleRepository();
|
||||
var deliveryRepository = new InMemoryDeliveryRepository();
|
||||
var lockRepository = new InMemoryLockRepository();
|
||||
var channelRepository = new InMemoryChannelRepository();
|
||||
var evaluator = new DefaultNotifyRuleEvaluator();
|
||||
var egressPolicy = new TestEgressPolicy { IsSealed = false };
|
||||
var options = Options.Create(new NotifierWorkerOptions
|
||||
{
|
||||
DefaultIdempotencyTtl = TimeSpan.FromMinutes(5)
|
||||
});
|
||||
|
||||
var processor = new NotifierEventProcessor(
|
||||
ruleRepository,
|
||||
deliveryRepository,
|
||||
lockRepository,
|
||||
channelRepository,
|
||||
evaluator,
|
||||
egressPolicy,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<NotifierEventProcessor>.Instance);
|
||||
|
||||
var rule = NotifyRule.Create(
|
||||
ruleId: "rule-1",
|
||||
@@ -40,12 +46,24 @@ public sealed class EventProcessorTests
|
||||
match: NotifyRuleMatch.Create(eventKinds: new[] { "policy.violation" }),
|
||||
actions: new[]
|
||||
{
|
||||
NotifyRuleAction.Create(
|
||||
actionId: "act-slack",
|
||||
channel: "chn-slack")
|
||||
});
|
||||
|
||||
ruleRepository.Seed("tenant-a", rule);
|
||||
NotifyRuleAction.Create(
|
||||
actionId: "act-slack",
|
||||
channel: "chn-slack")
|
||||
});
|
||||
|
||||
ruleRepository.Seed("tenant-a", rule);
|
||||
channelRepository.Seed(
|
||||
"tenant-a",
|
||||
NotifyChannel.Create(
|
||||
channelId: "chn-slack",
|
||||
tenantId: "tenant-a",
|
||||
name: "Slack #alerts",
|
||||
type: NotifyChannelType.Slack,
|
||||
config: NotifyChannelConfig.Create(
|
||||
secretRef: "ref://notify/channels/slack/alerts",
|
||||
target: "#alerts",
|
||||
endpoint: "https://hooks.slack.com/services/T000/B000/XYZ"),
|
||||
enabled: true));
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
@@ -77,7 +95,205 @@ public sealed class EventProcessorTests
|
||||
Assert.Equal("chn-slack", record.Metadata["channel"]);
|
||||
Assert.Equal(notifyEvent.EventId, record.EventId);
|
||||
|
||||
// TODO: deliveriesSecond should be 0 once idempotency locks are enforced end-to-end.
|
||||
// Assert.Equal(0, deliveriesSecond);
|
||||
}
|
||||
}
|
||||
// TODO: deliveriesSecond should be 0 once idempotency locks are enforced end-to-end.
|
||||
// Assert.Equal(0, deliveriesSecond);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAsync_SealedModeSkipsBlockedChannel()
|
||||
{
|
||||
var ruleRepository = new InMemoryRuleRepository();
|
||||
var deliveryRepository = new InMemoryDeliveryRepository();
|
||||
var lockRepository = new InMemoryLockRepository();
|
||||
var channelRepository = new InMemoryChannelRepository();
|
||||
var evaluator = new DefaultNotifyRuleEvaluator();
|
||||
var egressPolicy = new TestEgressPolicy
|
||||
{
|
||||
IsSealed = true,
|
||||
EvaluateCallback = request =>
|
||||
{
|
||||
if (request.Destination.Host.Contains("hooks.slack.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EgressDecision.Blocked(
|
||||
reason: "Destination is not allowlisted while sealed.",
|
||||
remediation: "Add the Slack webhook host to the sealed-mode allow list or switch to an enclave-safe channel.");
|
||||
}
|
||||
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
};
|
||||
var options = Options.Create(new NotifierWorkerOptions
|
||||
{
|
||||
DefaultIdempotencyTtl = TimeSpan.FromMinutes(5)
|
||||
});
|
||||
|
||||
var processor = new NotifierEventProcessor(
|
||||
ruleRepository,
|
||||
deliveryRepository,
|
||||
lockRepository,
|
||||
channelRepository,
|
||||
evaluator,
|
||||
egressPolicy,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<NotifierEventProcessor>.Instance);
|
||||
|
||||
var rule = NotifyRule.Create(
|
||||
ruleId: "rule-2",
|
||||
tenantId: "tenant-a",
|
||||
name: "Sealed mode routing",
|
||||
match: NotifyRuleMatch.Create(eventKinds: new[] { "policy.violation" }),
|
||||
actions: new[]
|
||||
{
|
||||
NotifyRuleAction.Create(
|
||||
actionId: "act-webhook",
|
||||
channel: "chn-webhook"),
|
||||
NotifyRuleAction.Create(
|
||||
actionId: "act-email",
|
||||
channel: "chn-email")
|
||||
});
|
||||
|
||||
ruleRepository.Seed("tenant-a", rule);
|
||||
channelRepository.Seed(
|
||||
"tenant-a",
|
||||
NotifyChannel.Create(
|
||||
channelId: "chn-webhook",
|
||||
tenantId: "tenant-a",
|
||||
name: "Slack #alerts",
|
||||
type: NotifyChannelType.Webhook,
|
||||
config: NotifyChannelConfig.Create(
|
||||
secretRef: "ref://notify/channels/webhook/alerts",
|
||||
endpoint: "https://hooks.slack.com/services/T000/B000/XYZ"),
|
||||
enabled: true),
|
||||
NotifyChannel.Create(
|
||||
channelId: "chn-email",
|
||||
tenantId: "tenant-a",
|
||||
name: "Email SOC",
|
||||
type: NotifyChannelType.Email,
|
||||
config: NotifyChannelConfig.Create(
|
||||
secretRef: "ref://notify/channels/email/soc",
|
||||
target: "soc@example.com"),
|
||||
enabled: true));
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "policy.violation",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: new JsonObject(),
|
||||
actor: "policy-engine",
|
||||
version: "1");
|
||||
|
||||
var deliveries = await processor.ProcessAsync(notifyEvent, "worker-1", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(1, deliveries);
|
||||
|
||||
var record = Assert.Single(deliveryRepository.Records("tenant-a"));
|
||||
Assert.Equal("chn-email", record.Metadata["channel"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAsync_SealedModeAllowsAllowlistedChannel()
|
||||
{
|
||||
var ruleRepository = new InMemoryRuleRepository();
|
||||
var deliveryRepository = new InMemoryDeliveryRepository();
|
||||
var lockRepository = new InMemoryLockRepository();
|
||||
var channelRepository = new InMemoryChannelRepository();
|
||||
var evaluator = new DefaultNotifyRuleEvaluator();
|
||||
var egressPolicy = new TestEgressPolicy
|
||||
{
|
||||
IsSealed = true,
|
||||
EvaluateCallback = _ => EgressDecision.Allowed
|
||||
};
|
||||
var options = Options.Create(new NotifierWorkerOptions
|
||||
{
|
||||
DefaultIdempotencyTtl = TimeSpan.FromMinutes(5)
|
||||
});
|
||||
|
||||
var processor = new NotifierEventProcessor(
|
||||
ruleRepository,
|
||||
deliveryRepository,
|
||||
lockRepository,
|
||||
channelRepository,
|
||||
evaluator,
|
||||
egressPolicy,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<NotifierEventProcessor>.Instance);
|
||||
|
||||
var rule = NotifyRule.Create(
|
||||
ruleId: "rule-3",
|
||||
tenantId: "tenant-a",
|
||||
name: "Allowlisted egress",
|
||||
match: NotifyRuleMatch.Create(eventKinds: new[] { "policy.violation" }),
|
||||
actions: new[]
|
||||
{
|
||||
NotifyRuleAction.Create(
|
||||
actionId: "act-webhook",
|
||||
channel: "chn-webhook")
|
||||
});
|
||||
|
||||
ruleRepository.Seed("tenant-a", rule);
|
||||
channelRepository.Seed(
|
||||
"tenant-a",
|
||||
NotifyChannel.Create(
|
||||
channelId: "chn-webhook",
|
||||
tenantId: "tenant-a",
|
||||
name: "Slack #alerts",
|
||||
type: NotifyChannelType.Webhook,
|
||||
config: NotifyChannelConfig.Create(
|
||||
secretRef: "ref://notify/channels/webhook/alerts",
|
||||
endpoint: "https://hooks.slack.com/services/T000/B000/XYZ"),
|
||||
enabled: true));
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "policy.violation",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: new JsonObject(),
|
||||
actor: "policy-engine",
|
||||
version: "1");
|
||||
|
||||
var deliveries = await processor.ProcessAsync(notifyEvent, "worker-1", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(1, deliveries);
|
||||
var record = Assert.Single(deliveryRepository.Records("tenant-a"));
|
||||
Assert.Equal("chn-webhook", record.Metadata["channel"]);
|
||||
}
|
||||
|
||||
private sealed class TestEgressPolicy : IEgressPolicy
|
||||
{
|
||||
public bool IsSealed { get; set; }
|
||||
|
||||
public EgressPolicyMode Mode => IsSealed ? EgressPolicyMode.Sealed : EgressPolicyMode.Unsealed;
|
||||
|
||||
public Func<EgressRequest, EgressDecision>? EvaluateCallback { get; set; }
|
||||
|
||||
public EgressDecision Evaluate(EgressRequest request)
|
||||
=> EvaluateCallback?.Invoke(request) ?? EgressDecision.Allowed;
|
||||
|
||||
public ValueTask<EgressDecision> EvaluateAsync(EgressRequest request, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(Evaluate(request));
|
||||
|
||||
public void EnsureAllowed(EgressRequest request)
|
||||
{
|
||||
var decision = Evaluate(request);
|
||||
if (!decision.IsAllowed)
|
||||
{
|
||||
throw new AirGapEgressBlockedException(
|
||||
request,
|
||||
decision.Reason ?? "Request blocked by test policy.",
|
||||
decision.Remediation ?? "Review sealed-mode configuration.",
|
||||
documentationUrl: null,
|
||||
supportContact: null);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask EnsureAllowedAsync(EgressRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureAllowed(request);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +56,9 @@ internal sealed class InMemoryRuleRepository : INotifyRuleRepository
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryDeliveryRepository : INotifyDeliveryRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, List<NotifyDelivery>> _deliveries = new(StringComparer.Ordinal);
|
||||
internal sealed class InMemoryDeliveryRepository : INotifyDeliveryRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, List<NotifyDelivery>> _deliveries = new(StringComparer.Ordinal);
|
||||
|
||||
public Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -128,11 +128,63 @@ internal sealed class InMemoryDeliveryRepository : INotifyDeliveryRepository
|
||||
|
||||
return Array.Empty<NotifyDelivery>();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryLockRepository : INotifyLockRepository
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
}
|
||||
|
||||
internal sealed class InMemoryChannelRepository : INotifyChannelRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyChannel>> _channels = new(StringComparer.Ordinal);
|
||||
|
||||
public Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
var map = _channels.GetOrAdd(channel.TenantId, _ => new ConcurrentDictionary<string, NotifyChannel>(StringComparer.Ordinal));
|
||||
map[channel.ChannelId] = channel;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_channels.TryGetValue(tenantId, out var map) && map.TryGetValue(channelId, out var channel))
|
||||
{
|
||||
return Task.FromResult<NotifyChannel?>(channel);
|
||||
}
|
||||
|
||||
return Task.FromResult<NotifyChannel?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyChannel>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_channels.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<NotifyChannel>>(map.Values.ToArray());
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyChannel>>(Array.Empty<NotifyChannel>());
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_channels.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
map.TryRemove(channelId, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Seed(string tenantId, params NotifyChannel[] channels)
|
||||
{
|
||||
var map = _channels.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyChannel>(StringComparer.Ordinal));
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
map[channel.ChannelId] = channel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryLockRepository : INotifyLockRepository
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private readonly Dictionary<(string TenantId, string Resource), (string Owner, DateTimeOffset Expiry)> _locks = new();
|
||||
|
||||
public int SuccessfulReservations { get; private set; }
|
||||
|
||||
@@ -1,35 +1,43 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Processing;
|
||||
|
||||
internal sealed class NotifierEventProcessor
|
||||
{
|
||||
private readonly INotifyRuleRepository _ruleRepository;
|
||||
private readonly INotifyDeliveryRepository _deliveryRepository;
|
||||
private readonly INotifyLockRepository _lockRepository;
|
||||
private readonly INotifyRuleEvaluator _ruleEvaluator;
|
||||
private readonly INotifyDeliveryRepository _deliveryRepository;
|
||||
private readonly INotifyLockRepository _lockRepository;
|
||||
private readonly INotifyChannelRepository _channelRepository;
|
||||
private readonly INotifyRuleEvaluator _ruleEvaluator;
|
||||
private readonly IEgressPolicy _egressPolicy;
|
||||
private readonly NotifierWorkerOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<NotifierEventProcessor> _logger;
|
||||
|
||||
public NotifierEventProcessor(
|
||||
INotifyRuleRepository ruleRepository,
|
||||
INotifyDeliveryRepository deliveryRepository,
|
||||
INotifyLockRepository lockRepository,
|
||||
INotifyRuleEvaluator ruleEvaluator,
|
||||
IOptions<NotifierWorkerOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<NotifierEventProcessor> logger)
|
||||
public NotifierEventProcessor(
|
||||
INotifyRuleRepository ruleRepository,
|
||||
INotifyDeliveryRepository deliveryRepository,
|
||||
INotifyLockRepository lockRepository,
|
||||
INotifyChannelRepository channelRepository,
|
||||
INotifyRuleEvaluator ruleEvaluator,
|
||||
IEgressPolicy egressPolicy,
|
||||
IOptions<NotifierWorkerOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<NotifierEventProcessor> logger)
|
||||
{
|
||||
_ruleRepository = ruleRepository ?? throw new ArgumentNullException(nameof(ruleRepository));
|
||||
_deliveryRepository = deliveryRepository ?? throw new ArgumentNullException(nameof(deliveryRepository));
|
||||
_lockRepository = lockRepository ?? throw new ArgumentNullException(nameof(lockRepository));
|
||||
_ruleEvaluator = ruleEvaluator ?? throw new ArgumentNullException(nameof(ruleEvaluator));
|
||||
_deliveryRepository = deliveryRepository ?? throw new ArgumentNullException(nameof(deliveryRepository));
|
||||
_lockRepository = lockRepository ?? throw new ArgumentNullException(nameof(lockRepository));
|
||||
_channelRepository = channelRepository ?? throw new ArgumentNullException(nameof(channelRepository));
|
||||
_ruleEvaluator = ruleEvaluator ?? throw new ArgumentNullException(nameof(ruleEvaluator));
|
||||
_egressPolicy = egressPolicy ?? throw new ArgumentNullException(nameof(egressPolicy));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
@@ -77,13 +85,74 @@ internal sealed class NotifierEventProcessor
|
||||
return 0;
|
||||
}
|
||||
|
||||
var created = 0;
|
||||
foreach (var outcome in outcomes)
|
||||
{
|
||||
foreach (var action in outcome.Actions)
|
||||
{
|
||||
var ttl = ResolveIdempotencyTtl(action);
|
||||
var idempotencyKey = IdempotencyKeyBuilder.Build(tenantId, outcome.Rule.RuleId, action.ActionId, notifyEvent);
|
||||
var channelCache = new Dictionary<string, NotifyChannel?>(StringComparer.Ordinal);
|
||||
|
||||
var created = 0;
|
||||
foreach (var outcome in outcomes)
|
||||
{
|
||||
foreach (var action in outcome.Actions)
|
||||
{
|
||||
if (!action.Enabled)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping disabled action {ActionId} for tenant {TenantId}, rule {RuleId}.",
|
||||
action.ActionId,
|
||||
tenantId,
|
||||
outcome.Rule.RuleId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(action.Channel))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Skipping action {ActionId} for tenant {TenantId}, rule {RuleId} because channel reference is missing.",
|
||||
action.ActionId,
|
||||
tenantId,
|
||||
outcome.Rule.RuleId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var channelId = action.Channel.Trim();
|
||||
if (!channelCache.TryGetValue(channelId, out var channel))
|
||||
{
|
||||
channel = await _channelRepository
|
||||
.GetAsync(tenantId, channelId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
channelCache[channelId] = channel;
|
||||
}
|
||||
|
||||
if (channel is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Skipping action {ActionId} for tenant {TenantId}, rule {RuleId}: channel {ChannelId} not found.",
|
||||
action.ActionId,
|
||||
tenantId,
|
||||
outcome.Rule.RuleId,
|
||||
channelId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping action {ActionId} for tenant {TenantId}, rule {RuleId}: channel {ChannelId} is disabled.",
|
||||
action.ActionId,
|
||||
tenantId,
|
||||
outcome.Rule.RuleId,
|
||||
channel.ChannelId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_egressPolicy.IsSealed && RequiresExternalEgress(channel))
|
||||
{
|
||||
if (!TryEnsureChannelAllowed(channel, action, notifyEvent, tenantId, outcome.Rule.RuleId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var ttl = ResolveIdempotencyTtl(action);
|
||||
var idempotencyKey = IdempotencyKeyBuilder.Build(tenantId, outcome.Rule.RuleId, action.ActionId, notifyEvent);
|
||||
|
||||
bool reserved;
|
||||
try
|
||||
@@ -144,22 +213,92 @@ internal sealed class NotifierEventProcessor
|
||||
return created;
|
||||
}
|
||||
|
||||
private TimeSpan ResolveIdempotencyTtl(NotifyRuleAction action)
|
||||
{
|
||||
if (action.Throttle is { Ticks: > 0 } throttle)
|
||||
{
|
||||
return throttle;
|
||||
}
|
||||
private TimeSpan ResolveIdempotencyTtl(NotifyRuleAction action)
|
||||
{
|
||||
if (action.Throttle is { Ticks: > 0 } throttle)
|
||||
{
|
||||
return throttle;
|
||||
}
|
||||
|
||||
if (_options.DefaultIdempotencyTtl > TimeSpan.Zero)
|
||||
{
|
||||
return _options.DefaultIdempotencyTtl;
|
||||
}
|
||||
|
||||
return TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string>> BuildDeliveryMetadata(NotifyRuleAction action)
|
||||
return TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
private bool TryEnsureChannelAllowed(
|
||||
NotifyChannel channel,
|
||||
NotifyRuleAction action,
|
||||
NotifyEvent notifyEvent,
|
||||
string tenantId,
|
||||
string ruleId)
|
||||
{
|
||||
var endpoint = ResolveChannelEndpoint(channel);
|
||||
if (endpoint is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sealed mode blocked action {ActionId} for tenant {TenantId}, rule {RuleId}: channel {ChannelId} ({ChannelType}) does not expose a valid endpoint. Event {EventId}. Configure enclave-safe channels (SMTP relay, syslog, file sink) or provide an allowlisted endpoint before unsealing.",
|
||||
action.ActionId,
|
||||
tenantId,
|
||||
ruleId,
|
||||
channel.ChannelId,
|
||||
channel.Type,
|
||||
notifyEvent.EventId);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var request = new EgressRequest(
|
||||
component: "Notifier",
|
||||
destination: endpoint,
|
||||
intent: "notify.channel.dispatch",
|
||||
operation: $"{ruleId}:{action.ActionId}");
|
||||
|
||||
_egressPolicy.EnsureAllowed(request);
|
||||
return true;
|
||||
}
|
||||
catch (AirGapEgressBlockedException ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Sealed mode blocked action {ActionId} for tenant {TenantId}, rule {RuleId}: channel {ChannelId} ({ChannelType}) attempted to reach {Destination}. Reason: {Reason}. Remediation: {Remediation}. Suggested fallback: use enclave-safe channels (SMTP relay, syslog, file sink).",
|
||||
action.ActionId,
|
||||
tenantId,
|
||||
ruleId,
|
||||
channel.ChannelId,
|
||||
channel.Type,
|
||||
ex.Request.Destination,
|
||||
ex.Reason,
|
||||
ex.Remediation);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RequiresExternalEgress(NotifyChannel channel)
|
||||
{
|
||||
return channel.Type switch
|
||||
{
|
||||
NotifyChannelType.Email => false,
|
||||
NotifyChannelType.Custom when string.IsNullOrWhiteSpace(channel.Config?.Endpoint) => false,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
private static Uri? ResolveChannelEndpoint(NotifyChannel channel)
|
||||
{
|
||||
var endpoint = channel.Config?.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Uri.TryCreate(endpoint.Trim(), UriKind.Absolute, out var uri) ? uri : null;
|
||||
}
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string>> BuildDeliveryMetadata(NotifyRuleAction action)
|
||||
{
|
||||
var metadata = new List<KeyValuePair<string, string>>
|
||||
{
|
||||
|
||||
@@ -2,7 +2,8 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Notify.Storage.Mongo;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
@@ -24,8 +25,10 @@ builder.Logging.AddSimpleConsole(options =>
|
||||
builder.Services.Configure<NotifierWorkerOptions>(builder.Configuration.GetSection("notifier:worker"));
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
var mongoSection = builder.Configuration.GetSection("notifier:storage:mongo");
|
||||
builder.Services.AddNotifyMongoStorage(mongoSection);
|
||||
var mongoSection = builder.Configuration.GetSection("notifier:storage:mongo");
|
||||
builder.Services.AddNotifyMongoStorage(mongoSection);
|
||||
|
||||
builder.Services.AddAirGapEgressPolicy(builder.Configuration);
|
||||
|
||||
builder.Services.AddNotifyEventQueue(builder.Configuration, "notifier:queue");
|
||||
builder.Services.AddHealthChecks().AddNotifyQueueHealthCheck();
|
||||
|
||||
@@ -20,5 +20,6 @@
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="../../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -51,10 +51,10 @@
|
||||
## Air-Gapped Mode (Epic 16)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| NOTIFY-AIRGAP-56-001 | TODO | Notifications Service Guild | AIRGAP-CTL-56-002, AIRGAP-POL-56-001 | Disable external webhook targets in sealed mode, default to enclave-safe channels (SMTP relay, syslog, file sink), and surface remediation guidance. | Sealed mode blocks external channels; configuration validation raises errors; tests cover allowances. |
|
||||
| NOTIFY-AIRGAP-56-002 | TODO | Notifications Service Guild, DevOps Guild | NOTIFY-AIRGAP-56-001, DEVOPS-AIRGAP-56-001 | Provide local notifier configurations bundled within Bootstrap Pack with deterministic secrets handling. | Offline config templates published; bootstrap script validated; docs updated. |
|
||||
| NOTIFY-AIRGAP-57-001 | TODO | Notifications Service Guild, AirGap Time Guild | NOTIFY-AIRGAP-56-001, AIRGAP-TIME-58-001 | Send staleness drift and bundle import notifications with remediation steps. | Notifications emitted on thresholds; tests cover suppression/resend. |
|
||||
| NOTIFY-AIRGAP-58-001 | TODO | Notifications Service Guild, Evidence Locker Guild | NOTIFY-AIRGAP-56-001, EVID-OBS-54-002 | Add portable evidence export completion notifications including checksum + location metadata. | Notification payload includes bundle details; audit logs recorded; CLI integration validated. |
|
||||
| NOTIFY-AIRGAP-56-001 | DONE | Notifications Service Guild | AIRGAP-CTL-56-002, AIRGAP-POL-56-001 | Disable external webhook targets in sealed mode, default to enclave-safe channels (SMTP relay, syslog, file sink), and surface remediation guidance. | Sealed mode blocks external channels; configuration validation raises errors; tests cover allowances. |
|
||||
| NOTIFY-AIRGAP-56-002 | DONE | Notifications Service Guild, DevOps Guild | NOTIFY-AIRGAP-56-001, DEVOPS-AIRGAP-56-001 | Provide local notifier configurations bundled within Bootstrap Pack with deterministic secrets handling. | Offline config templates published; bootstrap script validated; docs updated. |
|
||||
| NOTIFY-AIRGAP-57-001 | DONE | Notifications Service Guild, AirGap Time Guild | NOTIFY-AIRGAP-56-001, AIRGAP-TIME-58-001 | Send staleness drift and bundle import notifications with remediation steps. | Notifications emitted on thresholds; tests cover suppression/resend. |
|
||||
| NOTIFY-AIRGAP-58-001 | DONE | Notifications Service Guild, Evidence Locker Guild | NOTIFY-AIRGAP-56-001, EVID-OBS-54-002 | Add portable evidence export completion notifications including checksum + location metadata. | Notification payload includes bundle details; audit logs recorded; CLI integration validated. |
|
||||
|
||||
## SDKs & OpenAPI (Epic 17)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|
||||
Reference in New Issue
Block a user