work
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-25 08:01:23 +02:00
parent d92973d6fd
commit 6bee1fdcf5
207 changed files with 12816 additions and 2295 deletions

View File

@@ -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);
}
}

View File

@@ -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.");
}
}

View File

@@ -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);
}
}

View File

@@ -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.");
}
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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"
}
]
}

View File

@@ -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. |