work
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Notifier.Tests.Support;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
using StellaOps.Notify.Queue;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests;
|
||||
|
||||
public sealed class AttestationEventEndpointTests : IClassFixture<NotifierApplicationFactory>
|
||||
{
|
||||
private readonly NotifierApplicationFactory _factory;
|
||||
|
||||
public AttestationEventEndpointTests(NotifierApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Attestation_event_is_published_to_queue()
|
||||
{
|
||||
var recordingQueue = new RecordingNotifyEventQueue();
|
||||
|
||||
var client = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<INotifyEventQueue>();
|
||||
services.AddSingleton<INotifyEventQueue>(recordingQueue);
|
||||
});
|
||||
}).CreateClient();
|
||||
|
||||
var request = new AttestationEventRequest
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
Kind = "authority.keys.rotated",
|
||||
Actor = "authority",
|
||||
Timestamp = DateTimeOffset.Parse("2025-11-24T00:00:00Z"),
|
||||
Payload = new System.Text.Json.Nodes.JsonObject
|
||||
{
|
||||
["rotation"] = new System.Text.Json.Nodes.JsonObject
|
||||
{
|
||||
["batchId"] = "batch-42",
|
||||
["executedAt"] = "2025-11-24T00:00:00Z"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var message = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/attestation-events")
|
||||
{
|
||||
Content = JsonContent.Create(request)
|
||||
};
|
||||
message.Headers.Add("X-StellaOps-Tenant", "tenant-sample");
|
||||
|
||||
var response = await client.SendAsync(message, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
Assert.Single(recordingQueue.Published);
|
||||
|
||||
var published = recordingQueue.Published.Single();
|
||||
Assert.Equal("authority.keys.rotated", published.Event.Kind);
|
||||
Assert.Equal("tenant-sample", published.Event.Tenant);
|
||||
Assert.Equal("notify:events", published.Stream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Notifier.Tests.Support;
|
||||
using StellaOps.Notifier.WebService.Setup;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests;
|
||||
|
||||
public sealed class AttestationTemplateSeederTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SeedTemplates_and_routing_load_from_offline_bundle()
|
||||
{
|
||||
var templateRepo = new InMemoryTemplateRepository();
|
||||
var channelRepo = new InMemoryChannelRepository();
|
||||
var ruleRepo = new InMemoryRuleRepository();
|
||||
var logger = NullLogger<AttestationTemplateSeeder>.Instance;
|
||||
|
||||
var contentRoot = LocateRepoRoot();
|
||||
|
||||
var seededTemplates = await AttestationTemplateSeeder.SeedTemplatesAsync(
|
||||
templateRepo,
|
||||
contentRoot,
|
||||
logger,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var seededRouting = await AttestationTemplateSeeder.SeedRoutingAsync(
|
||||
channelRepo,
|
||||
ruleRepo,
|
||||
contentRoot,
|
||||
logger,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(seededTemplates >= 6, "Expected attestation templates to be seeded.");
|
||||
Assert.True(seededRouting >= 3, "Expected attestation routing seed to create channels and rules.");
|
||||
|
||||
var templates = await templateRepo.ListAsync("bootstrap", TestContext.Current.CancellationToken);
|
||||
Assert.Contains(templates, t => t.Key == "tmpl-attest-key-rotation");
|
||||
Assert.Contains(templates, t => t.Key == "tmpl-attest-transparency-anomaly");
|
||||
|
||||
var rules = await ruleRepo.ListAsync("bootstrap", TestContext.Current.CancellationToken);
|
||||
Assert.Contains(rules, r => r.Match.EventKinds.Contains("authority.keys.rotated"));
|
||||
Assert.Contains(rules, r => r.Match.EventKinds.Contains("attestor.transparency.anomaly"));
|
||||
}
|
||||
|
||||
private static string LocateRepoRoot()
|
||||
{
|
||||
var directory = AppContext.BaseDirectory;
|
||||
while (directory != null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory, "StellaOps.sln")) ||
|
||||
File.Exists(Path.Combine(directory, "StellaOps.Notifier.sln")))
|
||||
{
|
||||
return directory;
|
||||
}
|
||||
|
||||
directory = Directory.GetParent(directory)?.FullName;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unable to locate repository root.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Notifier.Tests.Support;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
using StellaOps.Notify.Queue;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests;
|
||||
|
||||
public sealed class RiskEventEndpointTests : IClassFixture<NotifierApplicationFactory>
|
||||
{
|
||||
private readonly NotifierApplicationFactory _factory;
|
||||
|
||||
public RiskEventEndpointTests(NotifierApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Risk_event_is_published_to_queue()
|
||||
{
|
||||
var recordingQueue = new RecordingNotifyEventQueue();
|
||||
|
||||
var client = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<INotifyEventQueue>();
|
||||
services.AddSingleton<INotifyEventQueue>(recordingQueue);
|
||||
});
|
||||
}).CreateClient();
|
||||
|
||||
var request = new RiskEventRequest
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
Kind = "risk.profile.severity.changed",
|
||||
Actor = "risk-engine",
|
||||
Timestamp = DateTimeOffset.Parse("2025-11-24T00:00:00Z"),
|
||||
Payload = new System.Text.Json.Nodes.JsonObject
|
||||
{
|
||||
["profile"] = new System.Text.Json.Nodes.JsonObject
|
||||
{
|
||||
["id"] = "stellaops://risk/profile/example@2025.11",
|
||||
["version"] = "2025.11"
|
||||
},
|
||||
["previous"] = new System.Text.Json.Nodes.JsonObject { ["severity"] = "medium" },
|
||||
["current"] = new System.Text.Json.Nodes.JsonObject { ["severity"] = "high" }
|
||||
}
|
||||
};
|
||||
|
||||
var message = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/risk-events")
|
||||
{
|
||||
Content = JsonContent.Create(request)
|
||||
};
|
||||
message.Headers.Add("X-StellaOps-Tenant", "tenant-sample");
|
||||
|
||||
var response = await client.SendAsync(message, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
Assert.Single(recordingQueue.Published);
|
||||
|
||||
var published = recordingQueue.Published.Single();
|
||||
Assert.Equal("risk.profile.severity.changed", published.Event.Kind);
|
||||
Assert.Equal("tenant-sample", published.Event.Tenant);
|
||||
Assert.Equal("notify:events", published.Stream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Notifier.Tests.Support;
|
||||
using StellaOps.Notifier.WebService.Setup;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests;
|
||||
|
||||
public sealed class RiskTemplateSeederTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SeedTemplates_and_routing_load_from_offline_bundle()
|
||||
{
|
||||
var templateRepo = new InMemoryTemplateRepository();
|
||||
var channelRepo = new InMemoryChannelRepository();
|
||||
var ruleRepo = new InMemoryRuleRepository();
|
||||
var logger = NullLogger<RiskTemplateSeeder>.Instance;
|
||||
|
||||
var contentRoot = LocateRepoRoot();
|
||||
|
||||
var seededTemplates = await RiskTemplateSeeder.SeedTemplatesAsync(
|
||||
templateRepo,
|
||||
contentRoot,
|
||||
logger,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var seededRouting = await RiskTemplateSeeder.SeedRoutingAsync(
|
||||
channelRepo,
|
||||
ruleRepo,
|
||||
contentRoot,
|
||||
logger,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(seededTemplates >= 4, "Expected risk templates to be seeded.");
|
||||
Assert.True(seededRouting >= 4, "Expected risk routing seed to create channels and rules.");
|
||||
|
||||
var templates = await templateRepo.ListAsync("bootstrap", TestContext.Current.CancellationToken);
|
||||
Assert.Contains(templates, t => t.Key == "tmpl-risk-severity-change");
|
||||
Assert.Contains(templates, t => t.Key == "tmpl-risk-profile-state");
|
||||
|
||||
var rules = await ruleRepo.ListAsync("bootstrap", TestContext.Current.CancellationToken);
|
||||
Assert.Contains(rules, r => r.Match.EventKinds.Contains("risk.profile.severity.changed"));
|
||||
Assert.Contains(rules, r => r.Match.EventKinds.Contains("risk.profile.published"));
|
||||
}
|
||||
|
||||
private static string LocateRepoRoot()
|
||||
{
|
||||
var directory = AppContext.BaseDirectory;
|
||||
while (directory != null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory, "StellaOps.sln")) ||
|
||||
File.Exists(Path.Combine(directory, "StellaOps.Notifier.sln")))
|
||||
{
|
||||
return directory;
|
||||
}
|
||||
|
||||
directory = Directory.GetParent(directory)?.FullName;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unable to locate repository root.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using StellaOps.Notify.Queue;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Support;
|
||||
|
||||
internal sealed class RecordingNotifyEventQueue : INotifyEventQueue
|
||||
{
|
||||
private readonly List<NotifyQueueEventMessage> _messages = new();
|
||||
|
||||
public IReadOnlyList<NotifyQueueEventMessage> Published => _messages;
|
||||
|
||||
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(Array.Empty<INotifyQueueLease<NotifyQueueEventMessage>>());
|
||||
|
||||
public ValueTask PublishAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_messages.Add(message);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
public sealed record AttestationEventRequest
|
||||
{
|
||||
public Guid EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event kind, e.g. authority.keys.rotated, authority.keys.revoked, attestor.transparency.anomaly.
|
||||
/// </summary>
|
||||
public string? Kind { get; init; }
|
||||
|
||||
public string? Actor { get; init; }
|
||||
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
|
||||
public JsonObject? Payload { get; init; }
|
||||
|
||||
public IDictionary<string, string>? Attributes { get; init; }
|
||||
|
||||
public string? ResumeToken { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
public sealed record RiskEventRequest
|
||||
{
|
||||
public Guid EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// risk.profile.severity.changed | risk.profile.published | risk.profile.deprecated | risk.profile.thresholds.changed
|
||||
/// </summary>
|
||||
public string? Kind { get; init; }
|
||||
|
||||
public string? Actor { get; init; }
|
||||
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
|
||||
public JsonObject? Payload { get; init; }
|
||||
|
||||
public IDictionary<string, string>? Attributes { get; init; }
|
||||
|
||||
public string? ResumeToken { get; init; }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -31,6 +32,8 @@ if (!isTesting)
|
||||
builder.Services.AddNotifyMongoStorage(mongoSection);
|
||||
builder.Services.AddHostedService<MongoInitializationHostedService>();
|
||||
builder.Services.AddHostedService<PackApprovalTemplateSeeder>();
|
||||
builder.Services.AddHostedService<AttestationTemplateSeeder>();
|
||||
builder.Services.AddHostedService<RiskTemplateSeeder>();
|
||||
}
|
||||
|
||||
// Fallback no-op event queue for environments that do not configure a real backend.
|
||||
@@ -173,6 +176,122 @@ app.MapPost("/api/v1/notify/pack-approvals", async (
|
||||
return Results.Accepted();
|
||||
});
|
||||
|
||||
app.MapPost("/api/v1/notify/attestation-events", async (
|
||||
HttpContext context,
|
||||
AttestationEventRequest request,
|
||||
INotifyEventQueue? eventQueue,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Kind))
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_request", "kind is required.", context));
|
||||
}
|
||||
|
||||
var eventId = request.EventId != Guid.Empty ? request.EventId : Guid.NewGuid();
|
||||
var ts = request.Timestamp is { } tsValue && tsValue != default ? tsValue : timeProvider.GetUtcNow();
|
||||
|
||||
if (eventQueue is not null)
|
||||
{
|
||||
var payload = request.Payload ?? new JsonObject();
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: eventId,
|
||||
kind: request.Kind!,
|
||||
tenant: tenantId,
|
||||
ts: ts,
|
||||
payload: payload,
|
||||
attributes: request.Attributes ?? new Dictionary<string, string>(),
|
||||
actor: request.Actor,
|
||||
version: "1");
|
||||
|
||||
var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(idempotencyKey))
|
||||
{
|
||||
idempotencyKey = $"attestation|{tenantId}|{notifyEvent.Kind}|{notifyEvent.EventId}";
|
||||
}
|
||||
|
||||
await eventQueue.PublishAsync(
|
||||
new NotifyQueueEventMessage(
|
||||
notifyEvent,
|
||||
stream: "notify:events",
|
||||
idempotencyKey: idempotencyKey,
|
||||
partitionKey: tenantId,
|
||||
traceId: context.TraceIdentifier),
|
||||
context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ResumeToken))
|
||||
{
|
||||
context.Response.Headers["X-Resume-After"] = request.ResumeToken;
|
||||
}
|
||||
|
||||
return Results.Accepted();
|
||||
});
|
||||
|
||||
app.MapPost("/api/v1/notify/risk-events", async (
|
||||
HttpContext context,
|
||||
RiskEventRequest request,
|
||||
INotifyEventQueue? eventQueue,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Kind))
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_request", "kind is required.", context));
|
||||
}
|
||||
|
||||
var eventId = request.EventId != Guid.Empty ? request.EventId : Guid.NewGuid();
|
||||
var ts = request.Timestamp is { } tsValue && tsValue != default ? tsValue : timeProvider.GetUtcNow();
|
||||
|
||||
if (eventQueue is not null)
|
||||
{
|
||||
var payload = request.Payload ?? new JsonObject();
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: eventId,
|
||||
kind: request.Kind!,
|
||||
tenant: tenantId,
|
||||
ts: ts,
|
||||
payload: payload,
|
||||
attributes: request.Attributes ?? new Dictionary<string, string>(),
|
||||
actor: request.Actor,
|
||||
version: "1");
|
||||
|
||||
var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(idempotencyKey))
|
||||
{
|
||||
idempotencyKey = $"risk|{tenantId}|{notifyEvent.Kind}|{notifyEvent.EventId}";
|
||||
}
|
||||
|
||||
await eventQueue.PublishAsync(
|
||||
new NotifyQueueEventMessage(
|
||||
notifyEvent,
|
||||
stream: "notify:events",
|
||||
idempotencyKey: idempotencyKey,
|
||||
partitionKey: tenantId,
|
||||
traceId: context.TraceIdentifier),
|
||||
context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ResumeToken))
|
||||
{
|
||||
context.Response.Headers["X-Resume-After"] = request.ResumeToken;
|
||||
}
|
||||
|
||||
return Results.Accepted();
|
||||
});
|
||||
|
||||
app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async (
|
||||
HttpContext context,
|
||||
string packId,
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Seeds attestation templates and default routing for dev/test/bootstrap scenarios.
|
||||
/// </summary>
|
||||
public sealed class AttestationTemplateSeeder : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IHostEnvironment _environment;
|
||||
private readonly ILogger<AttestationTemplateSeeder> _logger;
|
||||
|
||||
public AttestationTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger<AttestationTemplateSeeder> logger)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var templateRepo = scope.ServiceProvider.GetService<INotifyTemplateRepository>();
|
||||
var channelRepo = scope.ServiceProvider.GetService<INotifyChannelRepository>();
|
||||
var ruleRepo = scope.ServiceProvider.GetService<INotifyRuleRepository>();
|
||||
|
||||
if (templateRepo is null)
|
||||
{
|
||||
_logger.LogWarning("Template repository not registered; skipping attestation template seed.");
|
||||
return;
|
||||
}
|
||||
|
||||
var contentRoot = _environment.ContentRootPath;
|
||||
var templatesSeeded = await SeedTemplatesAsync(templateRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
|
||||
if (templatesSeeded > 0)
|
||||
{
|
||||
_logger.LogInformation("Seeded {TemplateCount} attestation templates from offline bundle.", templatesSeeded);
|
||||
}
|
||||
|
||||
if (channelRepo is null || ruleRepo is null)
|
||||
{
|
||||
_logger.LogWarning("Channel or rule repository not registered; skipping attestation routing seed.");
|
||||
return;
|
||||
}
|
||||
|
||||
var routingSeeded = await SeedRoutingAsync(channelRepo, ruleRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
|
||||
if (routingSeeded > 0)
|
||||
{
|
||||
_logger.LogInformation("Seeded default attestation routing (channels + rules).");
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public static async Task<int> SeedTemplatesAsync(
|
||||
INotifyTemplateRepository repository,
|
||||
string contentRootPath,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
var templateDir = LocateAttestationTemplatesPath(contentRootPath);
|
||||
if (templateDir is null)
|
||||
{
|
||||
logger.LogWarning("Attestation templates directory not found under {ContentRoot}; skipping seed.", contentRootPath);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
foreach (var file in Directory.EnumerateFiles(templateDir, "*.template.json", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
try
|
||||
{
|
||||
var template = await ToTemplateAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
await repository.UpsertAsync(template, cancellationToken).ConfigureAwait(false);
|
||||
count++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to seed template from {File}.", file);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public static async Task<int> SeedRoutingAsync(
|
||||
INotifyChannelRepository channelRepository,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
string contentRootPath,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channelRepository);
|
||||
ArgumentNullException.ThrowIfNull(ruleRepository);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
var samplePath = LocateAttestationRulesPath(contentRootPath);
|
||||
if (samplePath is null)
|
||||
{
|
||||
logger.LogWarning("Attestation rules sample not found under {ContentRoot}; skipping routing seed.", contentRootPath);
|
||||
return 0;
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(samplePath);
|
||||
var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tenant = "bootstrap";
|
||||
var channelsElement = doc.RootElement.GetProperty("channels");
|
||||
var rulesElement = doc.RootElement.GetProperty("rules");
|
||||
|
||||
var channels = channelsElement.EnumerateArray()
|
||||
.Select(ToChannel)
|
||||
.ToArray();
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
await channelRepository.UpsertAsync(channel with { TenantId = tenant }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var rule in rulesElement.EnumerateArray())
|
||||
{
|
||||
var model = ToRule(rule, tenant);
|
||||
await ruleRepository.UpsertAsync(model, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return channels.Length + rulesElement.GetArrayLength();
|
||||
}
|
||||
|
||||
private static NotifyRule ToRule(JsonElement element, string tenant)
|
||||
{
|
||||
var ruleId = element.GetProperty("ruleId").GetString() ?? throw new InvalidOperationException("ruleId missing");
|
||||
var name = element.GetProperty("name").GetString() ?? ruleId;
|
||||
var enabled = element.GetProperty("enabled").GetBoolean();
|
||||
var matchKinds = element.GetProperty("match").GetProperty("eventKinds").EnumerateArray().Select(p => p.GetString() ?? string.Empty).ToArray();
|
||||
|
||||
var actions = element.GetProperty("actions").EnumerateArray().Select(action =>
|
||||
NotifyRuleAction.Create(
|
||||
actionId: action.GetProperty("actionId").GetString() ?? throw new InvalidOperationException("actionId missing"),
|
||||
channel: action.GetProperty("channel").GetString() ?? string.Empty,
|
||||
template: action.GetProperty("template").GetString() ?? string.Empty,
|
||||
enabled: action.TryGetProperty("enabled", out var en) ? en.GetBoolean() : true)).ToArray();
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: ruleId,
|
||||
tenantId: tenant,
|
||||
name: name,
|
||||
match: NotifyRuleMatch.Create(eventKinds: matchKinds),
|
||||
actions: actions,
|
||||
enabled: enabled,
|
||||
description: "Seeded attestation routing rule.");
|
||||
}
|
||||
|
||||
private static NotifyChannel ToChannel(JsonElement element)
|
||||
{
|
||||
var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing");
|
||||
var type = ParseEnum<NotifyChannelType>(element.GetProperty("type").GetString(), NotifyChannelType.Custom);
|
||||
var name = element.GetProperty("name").GetString() ?? channelId;
|
||||
var target = element.TryGetProperty("target", out var t) ? t.GetString() : null;
|
||||
var endpoint = element.TryGetProperty("endpoint", out var e) ? e.GetString() : null;
|
||||
var secretRef = element.GetProperty("secretRef").GetString() ?? string.Empty;
|
||||
|
||||
var config = NotifyChannelConfig.Create(
|
||||
secretRef: secretRef,
|
||||
endpoint: endpoint,
|
||||
target: target);
|
||||
|
||||
return NotifyChannel.Create(
|
||||
channelId: channelId,
|
||||
tenantId: element.GetProperty("tenantId").GetString() ?? "bootstrap",
|
||||
name: name,
|
||||
type: type,
|
||||
config: config,
|
||||
description: element.TryGetProperty("description", out var d) ? d.GetString() : null);
|
||||
}
|
||||
|
||||
private static async Task<NotifyTemplate> ToTemplateAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var templateId = root.GetProperty("templateId").GetString() ?? Path.GetFileNameWithoutExtension(path);
|
||||
var tenantId = root.GetProperty("tenantId").GetString() ?? "bootstrap";
|
||||
var channelType = ParseEnum<NotifyChannelType>(root.GetProperty("channelType").GetString(), NotifyChannelType.Custom);
|
||||
var key = root.GetProperty("key").GetString() ?? throw new InvalidOperationException("key missing");
|
||||
var locale = root.GetProperty("locale").GetString() ?? "en-US";
|
||||
var renderMode = ParseEnum<NotifyTemplateRenderMode>(root.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown);
|
||||
var format = ParseEnum<NotifyDeliveryFormat>(root.GetProperty("format").GetString(), NotifyDeliveryFormat.Json);
|
||||
var description = root.TryGetProperty("description", out var desc) ? desc.GetString() : null;
|
||||
var body = root.GetProperty("body").GetString() ?? string.Empty;
|
||||
|
||||
var metadata = Enumerable.Empty<KeyValuePair<string, string>>();
|
||||
if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
metadata = meta.EnumerateObject().Select(p => new KeyValuePair<string, string>(p.Name, p.Value.GetString() ?? string.Empty));
|
||||
}
|
||||
|
||||
return NotifyTemplate.Create(
|
||||
templateId: templateId,
|
||||
tenantId: tenantId,
|
||||
channelType: channelType,
|
||||
key: key,
|
||||
locale: locale,
|
||||
body: body,
|
||||
renderMode: renderMode,
|
||||
format: format,
|
||||
description: description,
|
||||
metadata: metadata,
|
||||
createdBy: "seed:attestation");
|
||||
}
|
||||
|
||||
private static string? LocateAttestationTemplatesPath(string contentRootPath)
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(contentRootPath, "offline", "notifier", "templates", "attestation"),
|
||||
Path.Combine(contentRootPath, "..", "offline", "notifier", "templates", "attestation")
|
||||
};
|
||||
|
||||
return candidates.FirstOrDefault(Directory.Exists);
|
||||
}
|
||||
|
||||
private static string? LocateAttestationRulesPath(string contentRootPath)
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(contentRootPath, "StellaOps.Notifier.docs", "attestation-rules.sample.json"),
|
||||
Path.Combine(contentRootPath, "..", "StellaOps.Notifier.docs", "attestation-rules.sample.json"),
|
||||
Path.Combine(contentRootPath, "src", "Notifier", "StellaOps.Notifier", "docs", "attestation-rules.sample.json")
|
||||
};
|
||||
|
||||
return candidates.FirstOrDefault(File.Exists);
|
||||
}
|
||||
|
||||
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback) where TEnum : struct
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Xml;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Seeds risk templates and default routing for dev/test/bootstrap scenarios.
|
||||
/// </summary>
|
||||
public sealed class RiskTemplateSeeder : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IHostEnvironment _environment;
|
||||
private readonly ILogger<RiskTemplateSeeder> _logger;
|
||||
|
||||
public RiskTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger<RiskTemplateSeeder> logger)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var templateRepo = scope.ServiceProvider.GetService<INotifyTemplateRepository>();
|
||||
var channelRepo = scope.ServiceProvider.GetService<INotifyChannelRepository>();
|
||||
var ruleRepo = scope.ServiceProvider.GetService<INotifyRuleRepository>();
|
||||
|
||||
if (templateRepo is null)
|
||||
{
|
||||
_logger.LogWarning("Template repository not registered; skipping risk template seed.");
|
||||
return;
|
||||
}
|
||||
|
||||
var contentRoot = _environment.ContentRootPath;
|
||||
var templatesSeeded = await SeedTemplatesAsync(templateRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
|
||||
if (templatesSeeded > 0)
|
||||
{
|
||||
_logger.LogInformation("Seeded {TemplateCount} risk templates from offline bundle.", templatesSeeded);
|
||||
}
|
||||
|
||||
if (channelRepo is null || ruleRepo is null)
|
||||
{
|
||||
_logger.LogWarning("Channel or rule repository not registered; skipping risk routing seed.");
|
||||
return;
|
||||
}
|
||||
|
||||
var routingSeeded = await SeedRoutingAsync(channelRepo, ruleRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
|
||||
if (routingSeeded > 0)
|
||||
{
|
||||
_logger.LogInformation("Seeded default risk routing (channels + rules).");
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public static async Task<int> SeedTemplatesAsync(
|
||||
INotifyTemplateRepository repository,
|
||||
string contentRootPath,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
var templateDir = LocateRiskTemplatesPath(contentRootPath);
|
||||
if (templateDir is null)
|
||||
{
|
||||
logger.LogWarning("Risk templates directory not found under {ContentRoot}; skipping seed.", contentRootPath);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
foreach (var file in Directory.EnumerateFiles(templateDir, "*.template.json", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
try
|
||||
{
|
||||
var template = await ToTemplateAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
await repository.UpsertAsync(template, cancellationToken).ConfigureAwait(false);
|
||||
count++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to seed template from {File}.", file);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public static async Task<int> SeedRoutingAsync(
|
||||
INotifyChannelRepository channelRepository,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
string contentRootPath,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channelRepository);
|
||||
ArgumentNullException.ThrowIfNull(ruleRepository);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
var samplePath = LocateRiskRulesPath(contentRootPath);
|
||||
if (samplePath is null)
|
||||
{
|
||||
logger.LogWarning("Risk rules sample not found under {ContentRoot}; skipping routing seed.", contentRootPath);
|
||||
return 0;
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(samplePath);
|
||||
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tenant = "bootstrap";
|
||||
var channelsElement = doc.RootElement.GetProperty("channels");
|
||||
var rulesElement = doc.RootElement.GetProperty("rules");
|
||||
|
||||
var channels = channelsElement.EnumerateArray()
|
||||
.Select(ToChannel)
|
||||
.ToArray();
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
await channelRepository.UpsertAsync(channel with { TenantId = tenant }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var rule in rulesElement.EnumerateArray())
|
||||
{
|
||||
var model = ToRule(rule, tenant);
|
||||
await ruleRepository.UpsertAsync(model, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return channels.Length + rulesElement.GetArrayLength();
|
||||
}
|
||||
|
||||
private static NotifyRule ToRule(JsonElement element, string tenant)
|
||||
{
|
||||
var ruleId = element.GetProperty("ruleId").GetString() ?? throw new InvalidOperationException("ruleId missing");
|
||||
var name = element.GetProperty("name").GetString() ?? ruleId;
|
||||
var enabled = element.TryGetProperty("enabled", out var en) ? en.GetBoolean() : true;
|
||||
var matchKinds = element.GetProperty("match").GetProperty("eventKinds").EnumerateArray().Select(p => p.GetString() ?? string.Empty).ToArray();
|
||||
|
||||
var actions = element.GetProperty("actions").EnumerateArray().Select(action =>
|
||||
NotifyRuleAction.Create(
|
||||
actionId: action.GetProperty("actionId").GetString() ?? throw new InvalidOperationException("actionId missing"),
|
||||
channel: action.GetProperty("channel").GetString() ?? string.Empty,
|
||||
template: action.GetProperty("template").GetString() ?? string.Empty,
|
||||
locale: action.TryGetProperty("locale", out var loc) ? loc.GetString() : null,
|
||||
throttle: action.TryGetProperty("throttle", out var throttle) ? XmlConvert.ToTimeSpan(throttle.GetString() ?? string.Empty) : default,
|
||||
enabled: action.TryGetProperty("enabled", out var en) ? en.GetBoolean() : true)).ToArray();
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: ruleId,
|
||||
tenantId: tenant,
|
||||
name: name,
|
||||
match: NotifyRuleMatch.Create(eventKinds: matchKinds),
|
||||
actions: actions,
|
||||
enabled: enabled,
|
||||
description: "Seeded risk routing rule.");
|
||||
}
|
||||
|
||||
private static NotifyChannel ToChannel(JsonElement element)
|
||||
{
|
||||
var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing");
|
||||
var type = ParseEnum<NotifyChannelType>(element.GetProperty("type").GetString(), NotifyChannelType.Custom);
|
||||
var name = element.GetProperty("name").GetString() ?? channelId;
|
||||
var target = element.TryGetProperty("target", out var t) ? t.GetString() : null;
|
||||
var endpoint = element.TryGetProperty("endpoint", out var e) ? e.GetString() : null;
|
||||
var secretRef = element.GetProperty("secretRef").GetString() ?? string.Empty;
|
||||
|
||||
var config = NotifyChannelConfig.Create(
|
||||
secretRef: secretRef,
|
||||
endpoint: endpoint,
|
||||
target: target);
|
||||
|
||||
return NotifyChannel.Create(
|
||||
channelId: channelId,
|
||||
tenantId: element.GetProperty("tenantId").GetString() ?? "bootstrap",
|
||||
name: name,
|
||||
type: type,
|
||||
config: config,
|
||||
description: element.TryGetProperty("description", out var d) ? d.GetString() : null);
|
||||
}
|
||||
|
||||
private static async Task<NotifyTemplate> ToTemplateAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var templateId = root.GetProperty("templateId").GetString() ?? Path.GetFileNameWithoutExtension(path);
|
||||
var tenantId = root.GetProperty("tenantId").GetString() ?? "bootstrap";
|
||||
var channelType = ParseEnum<NotifyChannelType>(root.GetProperty("channelType").GetString(), NotifyChannelType.Custom);
|
||||
var key = root.GetProperty("key").GetString() ?? throw new InvalidOperationException("key missing");
|
||||
var locale = root.GetProperty("locale").GetString() ?? "en-US";
|
||||
var renderMode = ParseEnum<NotifyTemplateRenderMode>(root.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown);
|
||||
var format = ParseEnum<NotifyDeliveryFormat>(root.GetProperty("format").GetString(), NotifyDeliveryFormat.Json);
|
||||
var description = root.TryGetProperty("description", out var desc) ? desc.GetString() : null;
|
||||
var body = root.GetProperty("body").GetString() ?? string.Empty;
|
||||
|
||||
var metadata = Enumerable.Empty<KeyValuePair<string, string>>();
|
||||
if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
metadata = meta.EnumerateObject().Select(p => new KeyValuePair<string, string>(p.Name, p.Value.GetString() ?? string.Empty));
|
||||
}
|
||||
|
||||
return NotifyTemplate.Create(
|
||||
templateId: templateId,
|
||||
tenantId: tenantId,
|
||||
channelType: channelType,
|
||||
key: key,
|
||||
locale: locale,
|
||||
body: body,
|
||||
renderMode: renderMode,
|
||||
format: format,
|
||||
description: description,
|
||||
metadata: metadata,
|
||||
createdBy: "seed:risk");
|
||||
}
|
||||
|
||||
private static string? LocateRiskTemplatesPath(string contentRootPath)
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(contentRootPath, "offline", "notifier", "templates", "risk"),
|
||||
Path.Combine(contentRootPath, "..", "offline", "notifier", "templates", "risk")
|
||||
};
|
||||
|
||||
return candidates.FirstOrDefault(Directory.Exists);
|
||||
}
|
||||
|
||||
private static string? LocateRiskRulesPath(string contentRootPath)
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(contentRootPath, "StellaOps.Notifier.docs", "risk-rules.sample.json"),
|
||||
Path.Combine(contentRootPath, "..", "StellaOps.Notifier.docs", "risk-rules.sample.json"),
|
||||
Path.Combine(contentRootPath, "src", "Notifier", "StellaOps.Notifier", "StellaOps.Notifier.docs", "risk-rules.sample.json")
|
||||
};
|
||||
|
||||
return candidates.FirstOrDefault(File.Exists);
|
||||
}
|
||||
|
||||
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback) where TEnum : struct
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"ruleId": "risk-severity-change",
|
||||
"name": "Risk severity escalation/downgrade",
|
||||
"enabled": true,
|
||||
"tenantId": "bootstrap",
|
||||
"match": {
|
||||
"eventKinds": [
|
||||
"risk.profile.severity.changed"
|
||||
]
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"actionId": "act-risk-severity-slack",
|
||||
"enabled": true,
|
||||
"channel": "slack-risk",
|
||||
"template": "tmpl-risk-severity-change",
|
||||
"locale": "en-us",
|
||||
"throttle": "PT5M"
|
||||
},
|
||||
{
|
||||
"actionId": "act-risk-severity-email",
|
||||
"enabled": true,
|
||||
"channel": "email-risk",
|
||||
"template": "tmpl-risk-severity-change",
|
||||
"locale": "en-us",
|
||||
"throttle": "PT10M"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "risk-profile-state",
|
||||
"name": "Risk profile published/deprecated",
|
||||
"enabled": true,
|
||||
"tenantId": "bootstrap",
|
||||
"match": {
|
||||
"eventKinds": [
|
||||
"risk.profile.published",
|
||||
"risk.profile.deprecated",
|
||||
"risk.profile.thresholds.changed"
|
||||
]
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"actionId": "act-risk-profile-slack",
|
||||
"enabled": true,
|
||||
"channel": "slack-risk",
|
||||
"template": "tmpl-risk-profile-state",
|
||||
"locale": "en-us"
|
||||
},
|
||||
{
|
||||
"actionId": "act-risk-profile-email",
|
||||
"enabled": true,
|
||||
"channel": "email-risk",
|
||||
"template": "tmpl-risk-profile-state",
|
||||
"locale": "en-us"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"channels": [
|
||||
{
|
||||
"channelId": "slack-risk",
|
||||
"type": "slack",
|
||||
"tenantId": "bootstrap",
|
||||
"name": "Slack · Risk",
|
||||
"endpoint": "https://hooks.slack.local/services/T000/B000/RISK",
|
||||
"secretRef": "ref://notify/channels/slack/risk"
|
||||
},
|
||||
{
|
||||
"channelId": "email-risk",
|
||||
"type": "email",
|
||||
"tenantId": "bootstrap",
|
||||
"name": "Email · Risk",
|
||||
"target": "risk-team@example.com",
|
||||
"secretRef": "ref://notify/channels/email/risk"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,13 +3,13 @@
|
||||
| ID | Status | Owner(s) | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| NOTIFY-ATTEST-74-001 | DONE (2025-11-16) | Notifications Service Guild | Attestation template suite complete; Slack expiry template added; coverage tests guard required channels. |
|
||||
| NOTIFY-ATTEST-74-002 | TODO | Notifications Service Guild · KMS Guild | Wire notifications to key rotation/revocation events + transparency witness failures (depends on 74-001). |
|
||||
| NOTIFY-ATTEST-74-002 | DONE (2025-11-24) | Notifications Service Guild · KMS Guild | Attestation event ingestion endpoint + seed routing/templates for key rotation, revocation, and transparency witness failures. |
|
||||
| NOTIFY-OAS-61-001 | DONE (2025-11-17) | Notifications Service Guild · API Contracts Guild | OAS updated with rules/templates/incidents/quiet hours and standard error envelope. |
|
||||
| NOTIFY-OAS-61-002 | DONE (2025-11-17) | Notifications Service Guild | `.well-known/openapi` discovery endpoint with scope metadata implemented. |
|
||||
| NOTIFY-OAS-62-001 | DONE (2025-11-17) | Notifications Service Guild · SDK Generator Guild | SDK usage examples + smoke tests (depends on 61-002). |
|
||||
| NOTIFY-OAS-63-001 | DONE (2025-11-17) | Notifications Service Guild · API Governance Guild | Deprecation headers + template notices for retiring APIs (depends on 62-001). |
|
||||
| NOTIFY-OBS-51-001 | DONE (2025-11-22) | Notifications Service Guild · Observability Guild | SLO webhook sink validated (`HttpEgressSloSinkTests`, `EventProcessorTests`); TRX: `StellaOps.Notifier.Tests/TestResults/notifier-slo-tests.trx`. |
|
||||
| NOTIFY-OBS-55-001 | DONE (2025-11-22) | Notifications Service Guild · Ops Guild | Incident mode start/stop notifications; templates + importable rules with quiet-hour overrides and legal logging metadata. |
|
||||
| NOTIFY-RISK-66-001 | TODO | Notifications Service Guild · Risk Engine Guild | Trigger risk severity escalation/downgrade notifications (waiting on Policy export). |
|
||||
| NOTIFY-RISK-67-001 | TODO | Notifications Service Guild · Policy Guild | Notify when risk profiles publish/deprecate/threshold-change (depends on 66-001). |
|
||||
| NOTIFY-RISK-68-001 | TODO | Notifications Service Guild | Per-profile routing rules + quiet hours for risk alerts (depends on 67-001). |
|
||||
| NOTIFY-RISK-66-001 | DONE (2025-11-24) | Notifications Service Guild · Risk Engine Guild | Added risk-events endpoint + templates/rules for severity change notifications. |
|
||||
| NOTIFY-RISK-67-001 | DONE (2025-11-24) | Notifications Service Guild · Policy Guild | Added routing/templates for risk profile publish/deprecate/threshold change. |
|
||||
| NOTIFY-RISK-68-001 | DONE (2025-11-24) | Notifications Service Guild | Default routing seeds with throttles/locales for risk alerts. |
|
||||
|
||||
Reference in New Issue
Block a user