- Delete dead Notify Worker (NoOp handler) - Move 51 source files (endpoints, contracts, services, compat stores) - Transform namespaces from Notifier.WebService to Notify.WebService - Update DI registrations, WebSocket support, v2 endpoint mapping - Comment out notifier-web in compose, update gateway routes - Update architecture docs, port registry, rollout matrix - Notifier Worker stays as separate delivery engine container Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1907 lines
76 KiB
C#
1907 lines
76 KiB
C#
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using StellaOps.Localization;
|
|
using static StellaOps.Localization.T;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.Extensions.Primitives;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using Serilog;
|
|
using Serilog.Events;
|
|
using StellaOps.Auth.ServerIntegration;
|
|
using StellaOps.Auth.ServerIntegration.Tenancy;
|
|
using StellaOps.Configuration;
|
|
using StellaOps.Notify.Models;
|
|
using StellaOps.Notify.Persistence.Extensions;
|
|
using StellaOps.Notify.Persistence.Postgres;
|
|
using StellaOps.Notify.Persistence.Postgres.Models;
|
|
using StellaOps.Notify.Persistence.Postgres.Repositories;
|
|
// Alias to disambiguate from StellaOps.Notifier.Worker.Storage.INotifyAuditRepository
|
|
using INotifyAuditRepository = StellaOps.Notify.Persistence.Postgres.Repositories.INotifyAuditRepository;
|
|
using StellaOps.Notify.WebService.Contracts;
|
|
using StellaOps.Notify.WebService.Diagnostics;
|
|
using StellaOps.Notify.WebService.Extensions;
|
|
using StellaOps.Notify.WebService.Hosting;
|
|
using StellaOps.Notify.WebService.Internal;
|
|
using StellaOps.Notify.WebService.Options;
|
|
using StellaOps.Notify.WebService.Plugins;
|
|
using StellaOps.Notify.WebService.Security;
|
|
using StellaOps.Notify.WebService.Services;
|
|
using StellaOps.Plugin.DependencyInjection;
|
|
using StellaOps.Router.AspNet;
|
|
// Notifier Worker shared types (correlation, simulation, security, escalation, etc.)
|
|
using StellaOps.Cryptography;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Notify.Queue;
|
|
using StellaOps.Notify.WebService.Constants;
|
|
using StellaOps.Notify.WebService.Endpoints;
|
|
using StellaOps.Notify.WebService.Setup;
|
|
using StellaOps.Notify.WebService.Storage.Compat;
|
|
using StellaOps.Notifier.Worker.Channels;
|
|
using StellaOps.Notifier.Worker.Security;
|
|
using StellaOps.Notifier.Worker.StormBreaker;
|
|
using StellaOps.Notifier.Worker.DeadLetter;
|
|
using StellaOps.Notifier.Worker.Retention;
|
|
using StellaOps.Notifier.Worker.Observability;
|
|
using StellaOps.Notifier.Worker.Escalation;
|
|
using StellaOps.Notifier.Worker.Tenancy;
|
|
using StellaOps.Notifier.Worker.Templates;
|
|
using StellaOps.Notifier.Worker.Correlation;
|
|
using StellaOps.Notifier.Worker.Simulation;
|
|
using StellaOps.Notifier.Worker.Storage;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using System;
|
|
using System.Collections.Immutable;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Claims;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.Threading;
|
|
using System.Threading.RateLimiting;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Configuration.AddStellaOpsDefaults(options =>
|
|
{
|
|
options.BasePath = builder.Environment.ContentRootPath;
|
|
options.EnvironmentPrefix = "NOTIFY_";
|
|
options.ConfigureBuilder = configurationBuilder =>
|
|
{
|
|
configurationBuilder.AddNotifyYaml(Path.Combine(builder.Environment.ContentRootPath, "../etc/notify.yaml"));
|
|
};
|
|
});
|
|
|
|
var contentRootPath = builder.Environment.ContentRootPath;
|
|
|
|
var bootstrapOptions = builder.Configuration.BindOptions<NotifyWebServiceOptions>(
|
|
NotifyWebServiceOptions.SectionName,
|
|
(opts, _) =>
|
|
{
|
|
NotifyWebServiceOptionsPostConfigure.Apply(opts, contentRootPath);
|
|
NotifyWebServiceOptionsValidator.Validate(opts);
|
|
});
|
|
|
|
builder.Services.AddOptions<NotifyWebServiceOptions>()
|
|
.Bind(builder.Configuration.GetSection(NotifyWebServiceOptions.SectionName))
|
|
.PostConfigure(options =>
|
|
{
|
|
NotifyWebServiceOptionsPostConfigure.Apply(options, contentRootPath);
|
|
NotifyWebServiceOptionsValidator.Validate(options);
|
|
})
|
|
.ValidateOnStart();
|
|
|
|
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
|
|
{
|
|
var minimumLevel = MapLogLevel(bootstrapOptions.Telemetry.MinimumLogLevel);
|
|
|
|
loggerConfiguration
|
|
.MinimumLevel.Is(minimumLevel)
|
|
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
|
|
.Enrich.FromLogContext()
|
|
.WriteTo.Console();
|
|
});
|
|
|
|
builder.Services.AddSingleton(TimeProvider.System);
|
|
builder.Services.AddSingleton<StellaOps.Determinism.IGuidProvider, StellaOps.Determinism.SystemGuidProvider>();
|
|
builder.Services.AddSingleton<ServiceStatus>();
|
|
builder.Services.AddSingleton<NotifySchemaMigrationService>();
|
|
|
|
// PostgreSQL is the canonical Notify storage; enable Postgres-backed repositories.
|
|
builder.Services.AddNotifyPersistence(builder.Configuration, sectionName: "Postgres:Notify");
|
|
|
|
var pluginHostOptions = NotifyPluginHostFactory.Build(bootstrapOptions, contentRootPath);
|
|
builder.Services.AddSingleton(pluginHostOptions);
|
|
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
|
|
builder.Services.AddSingleton<INotifyPluginRegistry, NotifyPluginRegistry>();
|
|
builder.Services.AddSingleton<INotifyChannelTestService, NotifyChannelTestService>();
|
|
builder.Services.AddSingleton<INotifyChannelHealthService, NotifyChannelHealthService>();
|
|
|
|
// =========================================================================
|
|
// Notifier v2 DI registrations (merged from Notifier WebService)
|
|
// =========================================================================
|
|
|
|
builder.Services.AddSingleton<ICryptoHmac, DefaultCryptoHmac>();
|
|
|
|
// Core correlation engine registrations required by incident and escalation flows.
|
|
builder.Services.AddCorrelationServices(builder.Configuration);
|
|
|
|
// Rule evaluation + simulation services power /api/v2/simulate* endpoints.
|
|
builder.Services.AddSingleton<StellaOps.Notify.Engine.INotifyRuleEvaluator, StellaOps.Notifier.Worker.Processing.DefaultNotifyRuleEvaluator>();
|
|
SimulationServiceExtensions.AddSimulationServices(builder.Services, builder.Configuration);
|
|
|
|
// Fallback no-op event queue for environments that do not configure a real backend.
|
|
builder.Services.TryAddSingleton<INotifyEventQueue, NullNotifyEventQueue>();
|
|
|
|
// In-memory storage for Notifier v2 endpoints (fully qualified to avoid ambiguity with Notify.Persistence types)
|
|
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyChannelRepository, InMemoryNotifyRepositories>();
|
|
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyRuleRepository, InMemoryNotifyRepositories>();
|
|
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyTemplateRepository, InMemoryNotifyRepositories>();
|
|
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyDeliveryRepository, InMemoryNotifyRepositories>();
|
|
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyAuditRepository, InMemoryNotifyRepositories>();
|
|
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyLockRepository, InMemoryNotifyRepositories>();
|
|
builder.Services.AddSingleton<IInAppInboxStore, InMemoryInboxStore>();
|
|
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyInboxRepository, InMemoryInboxStore>();
|
|
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyLocalizationRepository, InMemoryNotifyRepositories>();
|
|
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyPackApprovalRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryPackApprovalRepository>();
|
|
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyThrottleConfigRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryThrottleConfigRepository>();
|
|
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyOperatorOverrideRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryOperatorOverrideRepository>();
|
|
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyQuietHoursRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryQuietHoursRepository>();
|
|
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyMaintenanceWindowRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryMaintenanceWindowRepository>();
|
|
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyEscalationPolicyRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryEscalationPolicyRepository>();
|
|
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyOnCallScheduleRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryOnCallScheduleRepository>();
|
|
|
|
// Correlation suppression services
|
|
builder.Services.Configure<SuppressionAuditOptions>(builder.Configuration.GetSection(SuppressionAuditOptions.SectionName));
|
|
builder.Services.Configure<OperatorOverrideOptions>(builder.Configuration.GetSection(OperatorOverrideOptions.SectionName));
|
|
builder.Services.AddSingleton<ISuppressionAuditLogger, InMemorySuppressionAuditLogger>();
|
|
builder.Services.AddSingleton<IThrottleConfigurationService, InMemoryThrottleConfigurationService>();
|
|
builder.Services.AddSingleton<IQuietHoursCalendarService, InMemoryQuietHoursCalendarService>();
|
|
builder.Services.AddSingleton<IOperatorOverrideService, InMemoryOperatorOverrideService>();
|
|
|
|
// Template service with enhanced renderer (worker contracts)
|
|
builder.Services.AddTemplateServices(options =>
|
|
{
|
|
var provenanceUrl = builder.Configuration["notifier:provenance:baseUrl"];
|
|
if (!string.IsNullOrWhiteSpace(provenanceUrl))
|
|
{
|
|
options.ProvenanceBaseUrl = provenanceUrl;
|
|
}
|
|
});
|
|
|
|
// Localization resolver with fallback chain
|
|
builder.Services.AddSingleton<StellaOps.Notify.Models.ILocalizationResolver, StellaOps.Notify.WebService.Services.DefaultLocalizationResolver>();
|
|
|
|
// Security services (ack tokens, webhook, HTML sanitizer, tenant isolation)
|
|
builder.Services.Configure<AckTokenOptions>(builder.Configuration.GetSection("notifier:security:ackToken"));
|
|
builder.Services.AddSingleton<IAckTokenService, HmacAckTokenService>();
|
|
builder.Services.Configure<WebhookSecurityOptions>(builder.Configuration.GetSection("notifier:security:webhook"));
|
|
builder.Services.AddSingleton<IWebhookSecurityService, InMemoryWebhookSecurityService>();
|
|
builder.Services.AddSingleton<IHtmlSanitizer, DefaultHtmlSanitizer>();
|
|
builder.Services.Configure<TenantIsolationOptions>(builder.Configuration.GetSection("notifier:security:tenantIsolation"));
|
|
builder.Services.AddSingleton<ITenantIsolationValidator, InMemoryTenantIsolationValidator>();
|
|
|
|
// Observability, dead-letter, and retention services
|
|
builder.Services.AddSingleton<INotifyMetrics, DefaultNotifyMetrics>();
|
|
builder.Services.AddSingleton<IDeadLetterService, InMemoryDeadLetterService>();
|
|
builder.Services.AddSingleton<IRetentionPolicyService, DefaultRetentionPolicyService>();
|
|
|
|
// Escalation and on-call services
|
|
builder.Services.AddEscalationServices(builder.Configuration);
|
|
|
|
// Storm breaker services
|
|
builder.Services.AddStormBreakerServices(builder.Configuration);
|
|
|
|
// Additional security services (signing, webhook validation)
|
|
builder.Services.AddNotifierSecurityServices(builder.Configuration);
|
|
|
|
// Tenancy services (context accessor, RLS enforcement, channel resolution, notification enrichment)
|
|
builder.Services.AddNotifierTenancy(builder.Configuration);
|
|
|
|
// Notifier WebService template/renderer services
|
|
builder.Services.AddSingleton<StellaOps.Notify.WebService.Services.INotifyTemplateService, StellaOps.Notify.WebService.Services.NotifyTemplateService>();
|
|
builder.Services.AddSingleton<StellaOps.Notify.WebService.Services.INotifyTemplateRenderer, StellaOps.Notify.WebService.Services.AdvancedTemplateRenderer>();
|
|
|
|
// Notifier authorization policies
|
|
builder.Services.AddAuthorization(options =>
|
|
{
|
|
options.AddStellaOpsScopePolicy(NotifierPolicies.NotifyViewer, StellaOpsScopes.NotifyViewer);
|
|
options.AddStellaOpsScopePolicy(NotifierPolicies.NotifyOperator, StellaOpsScopes.NotifyOperator);
|
|
options.AddStellaOpsScopePolicy(NotifierPolicies.NotifyAdmin, StellaOpsScopes.NotifyAdmin);
|
|
options.AddStellaOpsScopePolicy(NotifierPolicies.NotifyEscalate, StellaOpsScopes.NotifyEscalate);
|
|
});
|
|
|
|
builder.Services.AddHealthChecks();
|
|
|
|
// =========================================================================
|
|
|
|
ConfigureAuthentication(builder, bootstrapOptions, builder.Configuration);
|
|
ConfigureRateLimiting(builder, bootstrapOptions);
|
|
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
|
|
// Stella Router integration
|
|
var routerEnabled = builder.Services.AddRouterMicroservice(
|
|
builder.Configuration,
|
|
serviceName: "notify",
|
|
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
|
routerOptionsSection: "Router");
|
|
|
|
builder.Services.AddStellaOpsTenantServices();
|
|
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
|
builder.Services.AddStellaOpsLocalization(builder.Configuration);
|
|
builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly());
|
|
|
|
builder.TryAddStellaOpsLocalBinding("notify");
|
|
var app = builder.Build();
|
|
app.LogStellaOpsLocalHostname("notify");
|
|
|
|
var readyStatus = app.Services.GetRequiredService<ServiceStatus>();
|
|
|
|
var resolvedOptions = app.Services.GetRequiredService<IOptions<NotifyWebServiceOptions>>().Value;
|
|
await InitialiseAsync(app.Services, readyStatus, app.Logger, resolvedOptions);
|
|
|
|
ConfigureRequestPipeline(app, bootstrapOptions, routerEnabled);
|
|
|
|
// Enable WebSocket support for live incident feed (merged from Notifier)
|
|
app.UseWebSockets(new WebSocketOptions
|
|
{
|
|
KeepAliveInterval = TimeSpan.FromSeconds(30)
|
|
});
|
|
|
|
// Tenant context middleware (from Notifier merge)
|
|
app.UseTenantContext();
|
|
|
|
ConfigureEndpoints(app);
|
|
|
|
// =========================================================================
|
|
// Notifier v2 endpoint mappings (merged from Notifier WebService)
|
|
// =========================================================================
|
|
app.MapNotifyApiV2();
|
|
app.MapRuleEndpoints();
|
|
app.MapTemplateEndpoints();
|
|
app.MapIncidentEndpoints();
|
|
app.MapIncidentLiveFeed();
|
|
app.MapSimulationEndpoints();
|
|
app.MapQuietHoursEndpoints();
|
|
app.MapThrottleEndpoints();
|
|
app.MapOperatorOverrideEndpoints();
|
|
app.MapEscalationEndpoints();
|
|
app.MapStormBreakerEndpoints();
|
|
app.MapLocalizationEndpoints();
|
|
app.MapFallbackEndpoints();
|
|
app.MapSecurityEndpoints();
|
|
app.MapObservabilityEndpoints();
|
|
// =========================================================================
|
|
|
|
// Refresh Router endpoint cache
|
|
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
|
|
|
await app.LoadTranslationsAsync();
|
|
await app.RunAsync();
|
|
|
|
static void ConfigureAuthentication(WebApplicationBuilder builder, NotifyWebServiceOptions options, IConfiguration configuration)
|
|
{
|
|
// Read enabled flag from configuration to support test overrides via UseSetting
|
|
var authorityEnabled = configuration.GetValue<bool?>("notify:authority:enabled") ?? options.Authority.Enabled;
|
|
if (authorityEnabled)
|
|
{
|
|
builder.Services.AddStellaOpsResourceServerAuthentication(
|
|
builder.Configuration,
|
|
configurationSection: null,
|
|
configure: resourceOptions =>
|
|
{
|
|
resourceOptions.Authority = options.Authority.Issuer;
|
|
resourceOptions.RequireHttpsMetadata = options.Authority.RequireHttpsMetadata;
|
|
resourceOptions.MetadataAddress = options.Authority.MetadataAddress;
|
|
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(options.Authority.BackchannelTimeoutSeconds);
|
|
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(options.Authority.TokenClockSkewSeconds);
|
|
|
|
resourceOptions.Audiences.Clear();
|
|
foreach (var audience in options.Authority.Audiences)
|
|
{
|
|
resourceOptions.Audiences.Add(audience);
|
|
}
|
|
});
|
|
|
|
builder.Services.AddAuthorization(auth =>
|
|
{
|
|
auth.AddStellaOpsScopePolicy(NotifyPolicies.Viewer, options.Authority.ViewerScope);
|
|
auth.AddPolicy(
|
|
NotifyPolicies.Operator,
|
|
policy => policy
|
|
.RequireAuthenticatedUser()
|
|
.RequireAssertion(ctx =>
|
|
HasScope(ctx.User, options.Authority.OperatorScope) ||
|
|
HasScope(ctx.User, options.Authority.AdminScope)));
|
|
auth.AddStellaOpsScopePolicy(NotifyPolicies.Admin, options.Authority.AdminScope);
|
|
});
|
|
}
|
|
else
|
|
{
|
|
// Read allowAnonymousFallback from configuration to support test overrides
|
|
var allowAnonymous = configuration.GetValue<bool?>("notify:authority:allowAnonymousFallback") ?? options.Authority.AllowAnonymousFallback;
|
|
if (allowAnonymous)
|
|
{
|
|
builder.Services.AddAuthentication(authOptions =>
|
|
{
|
|
authOptions.DefaultAuthenticateScheme = AllowAllAuthenticationHandler.SchemeName;
|
|
authOptions.DefaultChallengeScheme = AllowAllAuthenticationHandler.SchemeName;
|
|
}).AddScheme<AuthenticationSchemeOptions, AllowAllAuthenticationHandler>(
|
|
AllowAllAuthenticationHandler.SchemeName,
|
|
static _ => { });
|
|
|
|
builder.Services.AddAuthorization(auth =>
|
|
{
|
|
auth.AddPolicy(
|
|
NotifyPolicies.Viewer,
|
|
policy => policy.RequireAssertion(_ => true));
|
|
|
|
auth.AddPolicy(
|
|
NotifyPolicies.Operator,
|
|
policy => policy.RequireAssertion(_ => true));
|
|
|
|
auth.AddPolicy(
|
|
NotifyPolicies.Admin,
|
|
policy => policy.RequireAssertion(_ => true));
|
|
});
|
|
}
|
|
else
|
|
{
|
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
.AddJwtBearer(jwt =>
|
|
{
|
|
jwt.RequireHttpsMetadata = false;
|
|
jwt.IncludeErrorDetails = true;
|
|
// Read JWT settings from configuration to support test overrides
|
|
var issuer = configuration["notify:authority:issuer"] ?? options.Authority.Issuer;
|
|
var audiencesList = configuration.GetSection("notify:authority:audiences").Get<string[]>() ?? options.Authority.Audiences.ToArray();
|
|
var signingKey = configuration["notify:authority:developmentSigningKey"] ?? options.Authority.DevelopmentSigningKey!;
|
|
|
|
jwt.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidIssuer = issuer,
|
|
ValidateAudience = audiencesList.Length > 0,
|
|
ValidAudiences = audiencesList,
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)),
|
|
ValidateLifetime = true,
|
|
ClockSkew = TimeSpan.FromSeconds(options.Authority.TokenClockSkewSeconds),
|
|
NameClaimType = ClaimTypes.Name
|
|
};
|
|
});
|
|
|
|
builder.Services.AddAuthorization(auth =>
|
|
{
|
|
auth.AddPolicy(
|
|
NotifyPolicies.Viewer,
|
|
policy => policy
|
|
.RequireAuthenticatedUser()
|
|
.RequireAssertion(ctx =>
|
|
HasScope(ctx.User, options.Authority.ViewerScope) ||
|
|
HasScope(ctx.User, options.Authority.OperatorScope) ||
|
|
HasScope(ctx.User, options.Authority.AdminScope)));
|
|
|
|
auth.AddPolicy(
|
|
NotifyPolicies.Operator,
|
|
policy => policy
|
|
.RequireAuthenticatedUser()
|
|
.RequireAssertion(ctx =>
|
|
HasScope(ctx.User, options.Authority.OperatorScope) ||
|
|
HasScope(ctx.User, options.Authority.AdminScope)));
|
|
|
|
auth.AddPolicy(
|
|
NotifyPolicies.Admin,
|
|
policy => policy
|
|
.RequireAuthenticatedUser()
|
|
.RequireAssertion(ctx => HasScope(ctx.User, options.Authority.AdminScope)));
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
static void ConfigureRateLimiting(WebApplicationBuilder builder, NotifyWebServiceOptions options)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
|
|
builder.Services.AddRateLimiter(rateLimiterOptions =>
|
|
{
|
|
rateLimiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
rateLimiterOptions.OnRejected = static (context, _) =>
|
|
{
|
|
context.HttpContext.Response.Headers.TryAdd("Retry-After", "1");
|
|
return ValueTask.CompletedTask;
|
|
};
|
|
|
|
ConfigurePolicy(rateLimiterOptions, NotifyRateLimitPolicies.DeliveryHistory, o => o.Api.RateLimits.DeliveryHistory, "deliveries");
|
|
ConfigurePolicy(rateLimiterOptions, NotifyRateLimitPolicies.TestSend, o => o.Api.RateLimits.TestSend, "channel-test");
|
|
});
|
|
|
|
static void ConfigurePolicy(
|
|
RateLimiterOptions rateLimiterOptions,
|
|
string policyName,
|
|
Func<NotifyWebServiceOptions, NotifyWebServiceOptions.RateLimitPolicyOptions> policySelector,
|
|
string prefix)
|
|
{
|
|
rateLimiterOptions.AddPolicy(policyName, httpContext =>
|
|
{
|
|
var opts = httpContext.RequestServices.GetRequiredService<IOptions<NotifyWebServiceOptions>>().Value;
|
|
var policy = policySelector(opts);
|
|
var tenantHeader = opts.Api.TenantHeader;
|
|
|
|
if (policy is null || !policy.Enabled)
|
|
{
|
|
return RateLimitPartition.GetNoLimiter("notify-disabled");
|
|
}
|
|
|
|
var identity = ResolveIdentity(httpContext, tenantHeader, prefix);
|
|
|
|
return RateLimitPartition.GetTokenBucketLimiter(identity, _ => new TokenBucketRateLimiterOptions
|
|
{
|
|
TokenLimit = policy.TokenLimit,
|
|
TokensPerPeriod = policy.TokensPerPeriod,
|
|
ReplenishmentPeriod = TimeSpan.FromSeconds(policy.ReplenishmentPeriodSeconds),
|
|
QueueLimit = policy.QueueLimit,
|
|
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
|
AutoReplenishment = true
|
|
});
|
|
});
|
|
}
|
|
|
|
static string ResolveIdentity(HttpContext httpContext, string tenantHeader, string prefix)
|
|
{
|
|
var tenant = httpContext.Request.Headers.TryGetValue(tenantHeader, out var header) && !StringValues.IsNullOrEmpty(header)
|
|
? header.ToString().Trim()
|
|
: "anonymous";
|
|
|
|
var subject = httpContext.User.FindFirst("sub")?.Value
|
|
?? httpContext.User.Identity?.Name
|
|
?? httpContext.Connection.RemoteIpAddress?.ToString()
|
|
?? "anonymous";
|
|
|
|
return string.Concat(prefix, ':', tenant, ':', subject);
|
|
}
|
|
}
|
|
|
|
static async Task InitialiseAsync(IServiceProvider services, ServiceStatus status, Microsoft.Extensions.Logging.ILogger logger, NotifyWebServiceOptions options)
|
|
{
|
|
var stopwatch = Stopwatch.StartNew();
|
|
|
|
try
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var registry = scope.ServiceProvider.GetRequiredService<INotifyPluginRegistry>();
|
|
var count = await registry.WarmupAsync();
|
|
|
|
stopwatch.Stop();
|
|
status.RecordReadyCheck(success: true, stopwatch.Elapsed);
|
|
logger.LogInformation("Notify WebService initialised in {ElapsedMs} ms; loaded {PluginCount} plug-in(s).", stopwatch.Elapsed.TotalMilliseconds, count);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
stopwatch.Stop();
|
|
status.RecordReadyCheck(success: false, stopwatch.Elapsed, ex.Message);
|
|
logger.LogError(ex, "Failed to initialise Notify WebService.");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
static void ConfigureRequestPipeline(WebApplication app, NotifyWebServiceOptions options, bool routerEnabled)
|
|
{
|
|
if (options.Telemetry.EnableRequestLogging)
|
|
{
|
|
app.UseSerilogRequestLogging(c =>
|
|
{
|
|
c.IncludeQueryInRequestPath = true;
|
|
c.GetLevel = (_, _, exception) => exception is null ? LogEventLevel.Information : LogEventLevel.Error;
|
|
});
|
|
}
|
|
|
|
app.UseStellaOpsCors();
|
|
app.UseStellaOpsLocalization();
|
|
app.UseAuthentication();
|
|
app.UseRateLimiter();
|
|
app.UseAuthorization();
|
|
app.UseStellaOpsTenantMiddleware();
|
|
|
|
// Stella Router integration
|
|
app.TryUseStellaRouter(routerEnabled);
|
|
}
|
|
|
|
static void ConfigureEndpoints(WebApplication app)
|
|
{
|
|
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }))
|
|
.WithName("NotifyHealthz")
|
|
.WithDescription(_t("notify.healthz.description"))
|
|
.AllowAnonymous();
|
|
|
|
app.MapGet("/readyz", (ServiceStatus status) =>
|
|
{
|
|
var snapshot = status.CreateSnapshot();
|
|
if (snapshot.Ready.IsReady)
|
|
{
|
|
return Results.Ok(new
|
|
{
|
|
status = "ready",
|
|
checkedAt = snapshot.Ready.CheckedAt,
|
|
latencyMs = snapshot.Ready.Latency?.TotalMilliseconds,
|
|
snapshot.StartedAt
|
|
});
|
|
}
|
|
|
|
return JsonResponse(
|
|
new
|
|
{
|
|
status = "unready",
|
|
snapshot.Ready.Error,
|
|
checkedAt = snapshot.Ready.CheckedAt,
|
|
latencyMs = snapshot.Ready.Latency?.TotalMilliseconds
|
|
},
|
|
StatusCodes.Status503ServiceUnavailable);
|
|
})
|
|
.WithName("NotifyReadyz")
|
|
.WithDescription(_t("notify.readyz.description"))
|
|
.AllowAnonymous();
|
|
|
|
var options = app.Services.GetRequiredService<IOptions<NotifyWebServiceOptions>>().Value;
|
|
var tenantHeader = options.Api.TenantHeader;
|
|
var apiBasePath = options.Api.BasePath.TrimEnd('/');
|
|
var apiGroup = app.MapGroup(options.Api.BasePath).RequireTenant();
|
|
var internalGroup = app.MapGroup(options.Api.InternalBasePath).RequireTenant();
|
|
|
|
internalGroup.MapPost("/rules/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeRule))
|
|
.WithName("notify.rules.normalize")
|
|
.WithDescription(_t("notify.internal.rules_normalize_description"))
|
|
.RequireAuthorization(NotifyPolicies.Operator)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest);
|
|
|
|
internalGroup.MapPost("/channels/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeChannel))
|
|
.WithName("notify.channels.normalize")
|
|
.WithDescription(_t("notify.internal.channels_normalize_description"))
|
|
.RequireAuthorization(NotifyPolicies.Operator);
|
|
|
|
internalGroup.MapPost("/templates/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeTemplate))
|
|
.WithName("notify.templates.normalize")
|
|
.WithDescription(_t("notify.internal.templates_normalize_description"))
|
|
.RequireAuthorization(NotifyPolicies.Operator);
|
|
|
|
apiGroup.MapGet("/rules", async (IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
var rules = await repository.ListAsync(tenant, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
return JsonResponse(rules.Select(ToNotifyRule));
|
|
})
|
|
.WithName("NotifyListRules")
|
|
.WithDescription(_t("notify.rules.list_description"))
|
|
.RequireAuthorization(NotifyPolicies.Viewer);
|
|
|
|
apiGroup.MapGet("/rules/{ruleId}", async (string ruleId, IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
if (!TryParseGuid(ruleId, out var id))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.rule_id_must_be_guid") });
|
|
}
|
|
|
|
var rule = await repository.GetByIdAsync(tenant, id, cancellationToken).ConfigureAwait(false);
|
|
return rule is null ? Results.NotFound() : JsonResponse(ToNotifyRule(rule));
|
|
})
|
|
.WithName("NotifyGetRule")
|
|
.WithDescription(_t("notify.rules.get_description"))
|
|
.RequireAuthorization(NotifyPolicies.Viewer);
|
|
|
|
apiGroup.MapPost("/rules", async (JsonNode? body, NotifySchemaMigrationService service, IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
if (body is null)
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.request_body_required") });
|
|
}
|
|
|
|
NotifyRule ruleModel;
|
|
try
|
|
{
|
|
ruleModel = service.UpgradeRule(body);
|
|
}
|
|
catch (Exception ex) when (ex is JsonException or InvalidOperationException or KeyNotFoundException or ArgumentException or FormatException)
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.rule_payload_invalid", ex.Message) });
|
|
}
|
|
|
|
if (!string.Equals(ruleModel.TenantId, tenant, StringComparison.Ordinal))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.tenant_mismatch") });
|
|
}
|
|
|
|
if (!TryParseGuid(ruleModel.RuleId, out var ruleGuid))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.rule_id_must_be_guid") });
|
|
}
|
|
|
|
var entity = ToRuleEntity(ruleModel);
|
|
var existing = await repository.GetByIdAsync(tenant, ruleGuid, cancellationToken).ConfigureAwait(false);
|
|
if (existing is null)
|
|
{
|
|
await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
await repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
return CreatedJson(BuildResourceLocation(apiBasePath, "rules", ruleModel.RuleId), ruleModel);
|
|
})
|
|
.WithName("NotifyUpsertRule")
|
|
.WithDescription(_t("notify.rules.upsert_description"))
|
|
.RequireAuthorization(NotifyPolicies.Operator);
|
|
|
|
apiGroup.MapDelete("/rules/{ruleId}", async (string ruleId, IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
if (!TryParseGuid(ruleId, out var ruleGuid))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.rule_id_must_be_guid") });
|
|
}
|
|
|
|
var deleted = await repository.DeleteAsync(tenant, ruleGuid, cancellationToken).ConfigureAwait(false);
|
|
return deleted ? Results.NoContent() : Results.NotFound();
|
|
})
|
|
.WithName("NotifyDeleteRule")
|
|
.WithDescription(_t("notify.rules.delete_description"))
|
|
.RequireAuthorization(NotifyPolicies.Operator);
|
|
|
|
apiGroup.MapGet("/channels", async (IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
var channels = await repository.GetAllAsync(tenant, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
return JsonResponse(channels.Select(ToNotifyChannel));
|
|
})
|
|
.WithName("NotifyListChannels")
|
|
.WithDescription(_t("notify.channels.list_description"))
|
|
.RequireAuthorization(NotifyPolicies.Viewer);
|
|
|
|
apiGroup.MapGet("/channels/{channelId}", async (string channelId, IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
if (!TryParseGuid(channelId, out var id))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") });
|
|
}
|
|
|
|
var channel = await repository.GetByIdAsync(tenant, id, cancellationToken).ConfigureAwait(false);
|
|
return channel is null ? Results.NotFound() : JsonResponse(ToNotifyChannel(channel));
|
|
})
|
|
.WithName("NotifyGetChannel")
|
|
.WithDescription(_t("notify.channels.get_description"))
|
|
.RequireAuthorization(NotifyPolicies.Viewer);
|
|
|
|
apiGroup.MapPost("/channels", async (JsonNode? body, NotifySchemaMigrationService service, IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
if (body is null)
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.request_body_required") });
|
|
}
|
|
|
|
NotifyChannel channelModel;
|
|
try
|
|
{
|
|
channelModel = service.UpgradeChannel(body);
|
|
}
|
|
catch (Exception ex) when (ex is System.Text.Json.JsonException or InvalidOperationException or KeyNotFoundException or ArgumentException or FormatException or NotSupportedException)
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.channel_payload_invalid", ex.Message) });
|
|
}
|
|
|
|
if (!string.Equals(channelModel.TenantId, tenant, StringComparison.Ordinal))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.tenant_mismatch") });
|
|
}
|
|
|
|
if (!TryParseGuid(channelModel.ChannelId, out var channelGuid))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") });
|
|
}
|
|
|
|
var entity = ToChannelEntity(channelModel);
|
|
var existing = await repository.GetByIdAsync(tenant, channelGuid, cancellationToken).ConfigureAwait(false);
|
|
if (existing is null)
|
|
{
|
|
await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
await repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
return CreatedJson(BuildResourceLocation(apiBasePath, "channels", channelModel.ChannelId), channelModel);
|
|
})
|
|
.WithName("NotifyUpsertChannel")
|
|
.WithDescription(_t("notify.channels.upsert_description"))
|
|
.RequireAuthorization(NotifyPolicies.Operator);
|
|
|
|
apiGroup.MapPost("/channels/{channelId}/test", async (
|
|
string channelId,
|
|
ChannelTestSendRequest? request,
|
|
IChannelRepository repository,
|
|
INotifyChannelTestService testService,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
if (request is null)
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.request_body_required") });
|
|
}
|
|
|
|
if (!TryParseGuid(channelId, out var channelGuid))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") });
|
|
}
|
|
|
|
var channelEntity = await repository.GetByIdAsync(tenant, channelGuid, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (channelEntity is null)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
var channel = ToNotifyChannel(channelEntity);
|
|
try
|
|
{
|
|
var response = await testService.SendAsync(
|
|
tenant,
|
|
channel,
|
|
request,
|
|
context.TraceIdentifier,
|
|
cancellationToken).ConfigureAwait(false);
|
|
return Results.Accepted(value: response);
|
|
}
|
|
catch (ChannelTestSendValidationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
})
|
|
.WithName("NotifyTestChannel")
|
|
.WithDescription(_t("notify.channels.test_description"))
|
|
.RequireAuthorization(NotifyPolicies.Operator)
|
|
.RequireRateLimiting(NotifyRateLimitPolicies.TestSend);
|
|
|
|
apiGroup.MapGet("/channels/{channelId}/health", async (
|
|
string channelId,
|
|
IChannelRepository repository,
|
|
INotifyChannelHealthService healthService,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
if (!TryParseGuid(channelId, out var channelGuid))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") });
|
|
}
|
|
|
|
var channelEntity = await repository.GetByIdAsync(tenant, channelGuid, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (channelEntity is null)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
var channel = ToNotifyChannel(channelEntity);
|
|
var response = await healthService.CheckAsync(
|
|
tenant,
|
|
channel,
|
|
context.TraceIdentifier,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
return Results.Ok(response);
|
|
})
|
|
.WithName("NotifyGetChannelHealth")
|
|
.WithDescription("Returns connector diagnostics for the specified channel.")
|
|
.RequireAuthorization(NotifyPolicies.Viewer);
|
|
|
|
apiGroup.MapDelete("/channels/{channelId}", async (string channelId, IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
if (!TryParseGuid(channelId, out var channelGuid))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") });
|
|
}
|
|
|
|
await repository.DeleteAsync(tenant, channelGuid, cancellationToken).ConfigureAwait(false);
|
|
return Results.NoContent();
|
|
})
|
|
.WithName("NotifyDeleteChannel")
|
|
.WithDescription(_t("notify.channels.delete_description"))
|
|
.RequireAuthorization(NotifyPolicies.Operator);
|
|
|
|
apiGroup.MapGet("/templates", async (ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
var templates = await repository.ListAsync(tenant, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
return JsonResponse(templates.Select(ToNotifyTemplate));
|
|
})
|
|
.WithName("NotifyListTemplates")
|
|
.WithDescription(_t("notify.templates.list_description"))
|
|
.RequireAuthorization(NotifyPolicies.Viewer);
|
|
|
|
apiGroup.MapGet("/templates/{templateId}", async (string templateId, ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
if (!TryParseGuid(templateId, out var templateGuid))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.template_id_must_be_guid") });
|
|
}
|
|
|
|
var template = await repository.GetByIdAsync(tenant, templateGuid, cancellationToken).ConfigureAwait(false);
|
|
return template is null ? Results.NotFound() : JsonResponse(ToNotifyTemplate(template));
|
|
})
|
|
.WithName("NotifyGetTemplate")
|
|
.WithDescription(_t("notify.templates.get_description"))
|
|
.RequireAuthorization(NotifyPolicies.Viewer);
|
|
|
|
apiGroup.MapPost("/templates", async (JsonNode? body, NotifySchemaMigrationService service, ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
if (body is null)
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.request_body_required") });
|
|
}
|
|
|
|
var templateModel = service.UpgradeTemplate(body);
|
|
if (!string.Equals(templateModel.TenantId, tenant, StringComparison.Ordinal))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.tenant_mismatch") });
|
|
}
|
|
|
|
if (!TryParseGuid(templateModel.TemplateId, out var templateGuid))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.template_id_must_be_guid") });
|
|
}
|
|
|
|
var entity = ToTemplateEntity(templateModel);
|
|
var existing = await repository.GetByIdAsync(tenant, templateGuid, cancellationToken).ConfigureAwait(false);
|
|
if (existing is null)
|
|
{
|
|
await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
await repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
return CreatedJson(BuildResourceLocation(apiBasePath, "templates", templateModel.TemplateId), templateModel);
|
|
})
|
|
.WithName("NotifyUpsertTemplate")
|
|
.WithDescription(_t("notify.templates.upsert_description"))
|
|
.RequireAuthorization(NotifyPolicies.Operator);
|
|
|
|
apiGroup.MapDelete("/templates/{templateId}", async (string templateId, ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
if (!TryParseGuid(templateId, out var templateGuid))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.template_id_must_be_guid") });
|
|
}
|
|
|
|
await repository.DeleteAsync(tenant, templateGuid, cancellationToken).ConfigureAwait(false);
|
|
return Results.NoContent();
|
|
})
|
|
.WithName("NotifyDeleteTemplate")
|
|
.WithDescription(_t("notify.templates.delete_description"))
|
|
.RequireAuthorization(NotifyPolicies.Operator);
|
|
|
|
apiGroup.MapPost("/deliveries", async ([FromBody] JsonNode? body, IDeliveryRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
if (body is null)
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.request_body_required") });
|
|
}
|
|
|
|
NotifyDelivery delivery;
|
|
try
|
|
{
|
|
delivery = NotifyCanonicalJsonSerializer.Deserialize<NotifyDelivery>(body.ToJsonString());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.delivery_payload_invalid", ex.Message) });
|
|
}
|
|
|
|
if (!string.Equals(delivery.TenantId, tenant, StringComparison.Ordinal))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.tenant_mismatch") });
|
|
}
|
|
|
|
if (!TryParseGuid(delivery.DeliveryId, out var deliveryId))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.delivery_id_must_be_guid") });
|
|
}
|
|
|
|
if (!TryParseGuid(delivery.ActionId, out var channelId))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.action_id_must_be_guid") });
|
|
}
|
|
|
|
if (!TryParseGuid(delivery.RuleId, out var ruleId))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.rule_id_must_be_guid") });
|
|
}
|
|
|
|
var entity = ToDeliveryEntity(delivery, deliveryId, channelId, ruleId, body);
|
|
var saved = await repository.UpsertAsync(entity, cancellationToken).ConfigureAwait(false);
|
|
|
|
return CreatedJson(
|
|
BuildResourceLocation(apiBasePath, "deliveries", delivery.DeliveryId),
|
|
ToDeliveryDetail(saved, channelName: null, channelType: null));
|
|
})
|
|
.WithName("NotifyCreateDelivery")
|
|
.WithDescription(_t("notify.deliveries.create_description"))
|
|
.RequireAuthorization(NotifyPolicies.Operator);
|
|
|
|
apiGroup.MapGet("/deliveries", async (
|
|
IDeliveryRepository repository,
|
|
IChannelRepository channelRepository,
|
|
HttpContext context,
|
|
[FromQuery] string? status,
|
|
[FromQuery] string? channelId,
|
|
[FromQuery] string? eventType,
|
|
[FromQuery] DateTimeOffset? since,
|
|
[FromQuery] DateTimeOffset? until,
|
|
[FromQuery] int? limit,
|
|
[FromQuery] int? offset,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
DeliveryStatus? statusFilter = null;
|
|
if (!string.IsNullOrWhiteSpace(status))
|
|
{
|
|
if (!Enum.TryParse<DeliveryStatus>(status, ignoreCase: true, out var parsed))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.delivery_status_unknown") });
|
|
}
|
|
|
|
statusFilter = parsed;
|
|
}
|
|
|
|
Guid? channelGuid = null;
|
|
if (!string.IsNullOrWhiteSpace(channelId))
|
|
{
|
|
if (!Guid.TryParse(channelId, out var parsedChannel))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") });
|
|
}
|
|
|
|
channelGuid = parsedChannel;
|
|
}
|
|
|
|
var take = Math.Clamp(limit ?? 100, 1, 500);
|
|
var skip = Math.Max(0, offset ?? 0);
|
|
|
|
var deliveries = await repository
|
|
.QueryAsync(tenant, statusFilter, channelGuid, eventType, since, until, take, skip, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var summaries = new List<object>(deliveries.Count);
|
|
foreach (var delivery in deliveries)
|
|
{
|
|
string? channelName = null;
|
|
string? channelType = null;
|
|
|
|
var channel = await channelRepository.GetByIdAsync(delivery.TenantId, delivery.ChannelId, cancellationToken).ConfigureAwait(false);
|
|
if (channel is not null)
|
|
{
|
|
channelName = channel.Name;
|
|
channelType = channel.ChannelType.ToString().ToLowerInvariant();
|
|
}
|
|
|
|
summaries.Add(ToDeliverySummary(delivery, channelName, channelType));
|
|
}
|
|
|
|
var hasMore = deliveries.Count == take;
|
|
var nextCursor = hasMore ? (skip + deliveries.Count).ToString(CultureInfo.InvariantCulture) : null;
|
|
|
|
return JsonResponse(new
|
|
{
|
|
items = summaries,
|
|
count = summaries.Count,
|
|
total = summaries.Count,
|
|
hasMore,
|
|
nextCursor,
|
|
continuationToken = nextCursor
|
|
});
|
|
})
|
|
.WithName("NotifyListDeliveries")
|
|
.WithDescription(_t("notify.deliveries.list_description"))
|
|
.RequireAuthorization(NotifyPolicies.Viewer)
|
|
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
|
|
|
|
apiGroup.MapGet("/delivery/stats", async (
|
|
IDeliveryRepository repository,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
var stats = await repository.GetStatsAsync(tenant!, now.AddHours(-24), now, cancellationToken);
|
|
var totalCompleted = stats.Sent + stats.Delivered + stats.Failed + stats.Bounced;
|
|
var successCount = stats.Sent + stats.Delivered;
|
|
var rate = totalCompleted > 0 ? (double)successCount / totalCompleted * 100.0 : 0.0;
|
|
|
|
return Results.Ok(new
|
|
{
|
|
totalSent = stats.Sent + stats.Delivered,
|
|
totalFailed = stats.Failed + stats.Bounced,
|
|
totalPending = stats.Pending,
|
|
successRate = Math.Round(rate, 1),
|
|
windowHours = 24,
|
|
evaluatedAt = now
|
|
});
|
|
})
|
|
.WithName("NotifyDeliveryStats")
|
|
.WithDescription("Get delivery statistics for the last 24 hours")
|
|
.RequireAuthorization(NotifyPolicies.Viewer);
|
|
|
|
apiGroup.MapGet("/deliveries/{deliveryId}", async (
|
|
string deliveryId,
|
|
IDeliveryRepository repository,
|
|
IChannelRepository channelRepository,
|
|
HttpContext context,
|
|
[FromQuery] string? tenant,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var headerResolved = TryResolveTenant(context, tenantHeader, out var tenantFromHeader, out var error);
|
|
var effectiveTenant = tenant ?? tenantFromHeader;
|
|
|
|
if (effectiveTenant is null && !headerResolved)
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
if (effectiveTenant is null)
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.tenant_required") });
|
|
}
|
|
|
|
if (!TryParseGuid(deliveryId, out var deliveryGuid))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.delivery_id_must_be_guid") });
|
|
}
|
|
|
|
var delivery = await repository.GetByIdAsync(effectiveTenant, deliveryGuid, cancellationToken).ConfigureAwait(false);
|
|
if (delivery is null)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
string? channelName = null;
|
|
string? channelType = null;
|
|
var channel = await channelRepository.GetByIdAsync(effectiveTenant, delivery.ChannelId, cancellationToken).ConfigureAwait(false);
|
|
if (channel is not null)
|
|
{
|
|
channelName = channel.Name;
|
|
channelType = channel.ChannelType.ToString().ToLowerInvariant();
|
|
}
|
|
|
|
return JsonResponse(ToDeliveryDetail(delivery, channelName, channelType));
|
|
})
|
|
.WithName("NotifyGetDelivery")
|
|
.WithDescription(_t("notify.deliveries.get_description"))
|
|
.RequireAuthorization(NotifyPolicies.Viewer)
|
|
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
|
|
|
|
apiGroup.MapPost("/digests", async ([FromBody] DigestUpsertRequest? request, IDigestRepository repository, TimeProvider timeProvider, StellaOps.Determinism.IGuidProvider guidProvider, HttpContext context, CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
if (request is null)
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.request_body_required") });
|
|
}
|
|
|
|
if (!TryParseGuid(request.ChannelId, out var channelIdGuid))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Recipient))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.recipient_required") });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.DigestKey))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.digest_key_required") });
|
|
}
|
|
|
|
var now = timeProvider.GetUtcNow();
|
|
var collectUntil = request.CollectUntil ?? now.AddHours(1);
|
|
var eventsJson = request.Events?.ToJsonString() ?? "[]";
|
|
var digest = new DigestEntity
|
|
{
|
|
Id = guidProvider.NewGuid(),
|
|
TenantId = tenant,
|
|
ChannelId = channelIdGuid,
|
|
Recipient = request.Recipient,
|
|
DigestKey = request.DigestKey,
|
|
EventCount = request.Events?.Count ?? 0,
|
|
Events = eventsJson,
|
|
Status = DigestStatus.Collecting,
|
|
CollectUntil = collectUntil,
|
|
SentAt = null,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
};
|
|
|
|
var saved = await repository.UpsertAsync(digest, cancellationToken).ConfigureAwait(false);
|
|
|
|
return CreatedJson(
|
|
BuildResourceLocation(apiBasePath, "digests", request.DigestKey),
|
|
ToDigestResponse(saved));
|
|
})
|
|
.WithName("NotifyUpsertDigest")
|
|
.WithDescription(_t("notify.digests.upsert_description"))
|
|
.RequireAuthorization(NotifyPolicies.Operator);
|
|
|
|
apiGroup.MapGet("/digests/{actionKey}", async (
|
|
string actionKey,
|
|
[FromQuery] string channelId,
|
|
[FromQuery] string recipient,
|
|
IDigestRepository repository,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
if (!TryParseGuid(channelId, out var channelGuid))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(recipient))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.recipient_required") });
|
|
}
|
|
|
|
var digest = await repository.GetByKeyAsync(tenant, channelGuid, recipient, actionKey, cancellationToken).ConfigureAwait(false);
|
|
return digest is null ? Results.NotFound() : JsonResponse(ToDigestResponse(digest));
|
|
})
|
|
.WithName("NotifyGetDigest")
|
|
.WithDescription(_t("notify.digests.get_description"))
|
|
.RequireAuthorization(NotifyPolicies.Viewer);
|
|
|
|
apiGroup.MapDelete("/digests/{actionKey}", async (
|
|
string actionKey,
|
|
[FromQuery] string channelId,
|
|
[FromQuery] string recipient,
|
|
IDigestRepository repository,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
if (!TryParseGuid(channelId, out var channelGuid))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(recipient))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.recipient_required") });
|
|
}
|
|
|
|
var deleted = await repository.DeleteByKeyAsync(tenant, channelGuid, recipient, actionKey, cancellationToken).ConfigureAwait(false);
|
|
return deleted ? Results.NoContent() : Results.NotFound();
|
|
})
|
|
.WithName("NotifyDeleteDigest")
|
|
.WithDescription(_t("notify.digests.delete_description"))
|
|
.RequireAuthorization(NotifyPolicies.Operator);
|
|
|
|
apiGroup.MapPost("/audit", async ([FromBody] JsonNode? body, INotifyAuditRepository repository, TimeProvider timeProvider, HttpContext context, ClaimsPrincipal user, CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
if (body is null)
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.request_body_required") });
|
|
}
|
|
|
|
var action = body["action"]?.GetValue<string>();
|
|
if (string.IsNullOrWhiteSpace(action))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.action_required") });
|
|
}
|
|
|
|
var entry = new NotifyAuditEntity
|
|
{
|
|
TenantId = tenant,
|
|
UserId = Guid.TryParse(user.FindFirstValue(ClaimTypes.NameIdentifier), out var userId) ? userId : null,
|
|
Action = action,
|
|
ResourceType = body["entityType"]?.GetValue<string>() ?? string.Empty,
|
|
ResourceId = body["entityId"]?.GetValue<string>(),
|
|
Details = body["payload"]?.ToJsonString(),
|
|
CorrelationId = context.TraceIdentifier,
|
|
CreatedAt = timeProvider.GetUtcNow()
|
|
};
|
|
|
|
var id = await repository.CreateAsync(entry, cancellationToken).ConfigureAwait(false);
|
|
return CreatedJson(BuildResourceLocation(apiBasePath, "audit", id.ToString()), new { id });
|
|
})
|
|
.WithName("NotifyCreateAuditEntry")
|
|
.WithDescription(_t("notify.audit.create_description"))
|
|
.RequireAuthorization(NotifyPolicies.Operator);
|
|
|
|
apiGroup.MapGet("/audit", async (INotifyAuditRepository repository, HttpContext context, [FromQuery] int? limit, [FromQuery] int? offset, CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
var results = await repository.ListAsync(tenant, limit ?? 100, offset ?? 0, cancellationToken).ConfigureAwait(false);
|
|
var payload = results.Select(a => new
|
|
{
|
|
a.Id,
|
|
a.TenantId,
|
|
a.UserId,
|
|
a.Action,
|
|
a.ResourceType,
|
|
a.ResourceId,
|
|
a.CorrelationId,
|
|
a.CreatedAt,
|
|
Details = string.IsNullOrWhiteSpace(a.Details) ? null : JsonNode.Parse(a.Details)
|
|
});
|
|
|
|
return JsonResponse(payload);
|
|
})
|
|
.WithName("NotifyListAuditEntries")
|
|
.WithDescription(_t("notify.audit.list_description"))
|
|
.RequireAuthorization(NotifyPolicies.Viewer);
|
|
|
|
apiGroup.MapPost("/locks/acquire", async ([FromBody] AcquireLockRequest request, ILockRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
var acquired = await repository.TryAcquireAsync(tenant, request.Resource, request.Owner, TimeSpan.FromSeconds(request.TtlSeconds), cancellationToken).ConfigureAwait(false);
|
|
return JsonResponse(new { acquired });
|
|
})
|
|
.WithName("NotifyAcquireLock")
|
|
.WithDescription(_t("notify.locks.acquire_description"))
|
|
.RequireAuthorization(NotifyPolicies.Operator);
|
|
|
|
apiGroup.MapPost("/locks/release", async ([FromBody] ReleaseLockRequest request, ILockRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
|
{
|
|
return error!;
|
|
}
|
|
|
|
var released = await repository.ReleaseAsync(tenant, request.Resource, request.Owner, cancellationToken).ConfigureAwait(false);
|
|
return released ? Results.NoContent() : Results.NotFound();
|
|
})
|
|
.WithName("NotifyReleaseLock")
|
|
.WithDescription(_t("notify.locks.release_description"))
|
|
.RequireAuthorization(NotifyPolicies.Operator);
|
|
}
|
|
|
|
static bool TryParseGuid(string value, out Guid guid) => Guid.TryParse(value, out guid);
|
|
|
|
static RuleEntity ToRuleEntity(NotifyRule rule)
|
|
{
|
|
var channelIds = rule.Actions.Select(action => ParseRequiredGuid(action.Channel, "channel"))
|
|
.ToArray();
|
|
var templateId = rule.Actions
|
|
.Select(action => action.Template)
|
|
.Where(id => id is not null)
|
|
.Select(id => Guid.TryParse(id, out var parsed) ? parsed : (Guid?)null)
|
|
.FirstOrDefault();
|
|
|
|
return new RuleEntity
|
|
{
|
|
Id = ParseRequiredGuid(rule.RuleId, "ruleId"),
|
|
TenantId = rule.TenantId,
|
|
Name = rule.Name,
|
|
Description = rule.Description ?? string.Empty,
|
|
Enabled = rule.Enabled,
|
|
Priority = 0,
|
|
EventTypes = rule.Match.EventKinds.ToArray(),
|
|
Filter = NotifyCanonicalJsonSerializer.Serialize(rule.Match),
|
|
ChannelIds = channelIds,
|
|
TemplateId = templateId,
|
|
Metadata = NotifyCanonicalJsonSerializer.Serialize(rule),
|
|
CreatedAt = rule.CreatedAt,
|
|
UpdatedAt = rule.UpdatedAt
|
|
};
|
|
}
|
|
|
|
static NotifyRule ToNotifyRule(RuleEntity entity)
|
|
{
|
|
var match = string.IsNullOrWhiteSpace(entity.Filter)
|
|
? NotifyRuleMatch.Create()
|
|
: NotifyCanonicalJsonSerializer.Deserialize<NotifyRuleMatch>(entity.Filter);
|
|
|
|
var actions = entity.ChannelIds.Select((channelId, index) =>
|
|
NotifyRuleAction.Create(
|
|
actionId: $"action-{index + 1}",
|
|
channel: channelId.ToString(),
|
|
template: entity.TemplateId?.ToString()))
|
|
.ToImmutableArray();
|
|
|
|
var metadata = DeserializeMetadata(entity.Metadata);
|
|
|
|
return NotifyRule.Create(
|
|
entity.Id.ToString(),
|
|
entity.TenantId,
|
|
entity.Name,
|
|
match,
|
|
actions,
|
|
enabled: entity.Enabled,
|
|
description: entity.Description,
|
|
metadata: metadata,
|
|
createdAt: entity.CreatedAt,
|
|
updatedAt: entity.UpdatedAt);
|
|
}
|
|
|
|
static ChannelEntity ToChannelEntity(NotifyChannel channel)
|
|
{
|
|
return new ChannelEntity
|
|
{
|
|
Id = ParseRequiredGuid(channel.ChannelId, "channelId"),
|
|
TenantId = channel.TenantId,
|
|
Name = channel.Name,
|
|
ChannelType = ToStorageChannelType(channel.Type),
|
|
Enabled = channel.Enabled,
|
|
Config = NotifyCanonicalJsonSerializer.Serialize(channel.Config),
|
|
Metadata = NotifyCanonicalJsonSerializer.Serialize(channel),
|
|
CreatedAt = channel.CreatedAt,
|
|
UpdatedAt = channel.UpdatedAt,
|
|
CreatedBy = channel.CreatedBy
|
|
};
|
|
}
|
|
|
|
static NotifyChannel ToNotifyChannel(ChannelEntity entity)
|
|
{
|
|
var metadataModel = TryDeserialize<NotifyChannel>(entity.Metadata);
|
|
if (metadataModel is not null)
|
|
{
|
|
return metadataModel;
|
|
}
|
|
|
|
var config = ToNotifyChannelConfig(entity);
|
|
|
|
return NotifyChannel.Create(
|
|
entity.Id.ToString(),
|
|
entity.TenantId,
|
|
entity.Name,
|
|
ToModelChannelType(entity.ChannelType),
|
|
config,
|
|
enabled: entity.Enabled,
|
|
createdBy: entity.CreatedBy,
|
|
createdAt: entity.CreatedAt,
|
|
updatedAt: entity.UpdatedAt);
|
|
}
|
|
|
|
static TemplateEntity ToTemplateEntity(NotifyTemplate template)
|
|
{
|
|
return new TemplateEntity
|
|
{
|
|
Id = ParseRequiredGuid(template.TemplateId, "templateId"),
|
|
TenantId = template.TenantId,
|
|
Name = template.Key,
|
|
ChannelType = ToStorageChannelType(template.ChannelType),
|
|
BodyTemplate = template.Body,
|
|
Locale = template.Locale,
|
|
Metadata = NotifyCanonicalJsonSerializer.Serialize(template),
|
|
CreatedAt = template.CreatedAt,
|
|
UpdatedAt = template.UpdatedAt,
|
|
SubjectTemplate = template.Description
|
|
};
|
|
}
|
|
|
|
static NotifyTemplate ToNotifyTemplate(TemplateEntity entity)
|
|
{
|
|
var metadataModel = TryDeserialize<NotifyTemplate>(entity.Metadata);
|
|
if (metadataModel is not null)
|
|
{
|
|
return metadataModel;
|
|
}
|
|
|
|
return NotifyTemplate.Create(
|
|
entity.Id.ToString(),
|
|
entity.TenantId,
|
|
ToModelChannelType(entity.ChannelType),
|
|
entity.Name,
|
|
entity.Locale,
|
|
entity.BodyTemplate,
|
|
description: entity.SubjectTemplate,
|
|
metadata: DeserializeMetadata(entity.Metadata),
|
|
createdAt: entity.CreatedAt,
|
|
updatedAt: entity.UpdatedAt);
|
|
}
|
|
|
|
static ChannelType ToStorageChannelType(NotifyChannelType type) => type switch
|
|
{
|
|
NotifyChannelType.Email => ChannelType.Email,
|
|
NotifyChannelType.Slack => ChannelType.Slack,
|
|
NotifyChannelType.Teams => ChannelType.Teams,
|
|
NotifyChannelType.Webhook => ChannelType.Webhook,
|
|
NotifyChannelType.PagerDuty => ChannelType.PagerDuty,
|
|
NotifyChannelType.OpsGenie => ChannelType.OpsGenie,
|
|
_ => ChannelType.Webhook
|
|
};
|
|
|
|
static NotifyChannelType ToModelChannelType(ChannelType type) => type switch
|
|
{
|
|
ChannelType.Email => NotifyChannelType.Email,
|
|
ChannelType.Slack => NotifyChannelType.Slack,
|
|
ChannelType.Teams => NotifyChannelType.Teams,
|
|
ChannelType.Webhook => NotifyChannelType.Webhook,
|
|
ChannelType.PagerDuty => NotifyChannelType.PagerDuty,
|
|
ChannelType.OpsGenie => NotifyChannelType.OpsGenie,
|
|
_ => NotifyChannelType.Webhook
|
|
};
|
|
|
|
static Guid ParseRequiredGuid(string value, string field)
|
|
{
|
|
if (Guid.TryParse(value, out var guid))
|
|
{
|
|
return guid;
|
|
}
|
|
|
|
throw new InvalidOperationException($"{field} must be a GUID.");
|
|
}
|
|
|
|
static ImmutableDictionary<string, string>? DeserializeMetadata(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
var dictionary = JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new Dictionary<string, string>();
|
|
return dictionary.ToImmutableDictionary(StringComparer.Ordinal);
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static DeliveryEntity ToDeliveryEntity(NotifyDelivery delivery, Guid deliveryId, Guid channelId, Guid ruleId, JsonNode rawPayload)
|
|
{
|
|
var correlationId = delivery.Metadata.TryGetValue("traceId", out var trace) ? trace : null;
|
|
var recipient = delivery.Rendered?.Target;
|
|
if (string.IsNullOrWhiteSpace(recipient) && delivery.Metadata.TryGetValue("target", out var target))
|
|
{
|
|
recipient = target;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(recipient))
|
|
{
|
|
recipient = "unknown";
|
|
}
|
|
|
|
return new DeliveryEntity
|
|
{
|
|
Id = deliveryId,
|
|
TenantId = delivery.TenantId,
|
|
ChannelId = channelId,
|
|
RuleId = ruleId,
|
|
TemplateId = null,
|
|
Status = ToStorageDeliveryStatus(delivery.Status),
|
|
Recipient = recipient,
|
|
Subject = delivery.Rendered?.Title,
|
|
Body = delivery.Rendered?.Body,
|
|
EventType = delivery.Kind,
|
|
EventPayload = rawPayload.ToJsonString(),
|
|
Attempt = delivery.Attempts.Length,
|
|
MaxAttempts = 3,
|
|
NextRetryAt = null,
|
|
ErrorMessage = delivery.StatusReason,
|
|
ExternalId = null,
|
|
CorrelationId = correlationId,
|
|
CreatedAt = delivery.CreatedAt,
|
|
QueuedAt = null,
|
|
SentAt = delivery.SentAt,
|
|
DeliveredAt = delivery.CompletedAt,
|
|
FailedAt = delivery.Status == NotifyDeliveryStatus.Failed ? delivery.CompletedAt : null
|
|
};
|
|
}
|
|
|
|
static object ToDeliverySummary(DeliveryEntity entity, string? channelName, string? channelType)
|
|
=> new
|
|
{
|
|
deliveryId = entity.Id.ToString(),
|
|
channelId = entity.ChannelId.ToString(),
|
|
channelName,
|
|
channelType,
|
|
eventType = entity.EventType,
|
|
status = DeliveryStatusToString(entity.Status),
|
|
attemptCount = entity.Attempt,
|
|
createdAt = entity.CreatedAt,
|
|
sentAt = entity.SentAt,
|
|
latencyMs = entity.SentAt is { } sent ? (long?)(sent - entity.CreatedAt).TotalMilliseconds : null
|
|
};
|
|
|
|
static object ToDeliveryDetail(DeliveryEntity entity, string? channelName, string? channelType)
|
|
=> new
|
|
{
|
|
deliveryId = entity.Id.ToString(),
|
|
tenantId = entity.TenantId,
|
|
channelId = entity.ChannelId.ToString(),
|
|
channelName,
|
|
channelType,
|
|
ruleId = entity.RuleId?.ToString(),
|
|
eventType = entity.EventType,
|
|
status = DeliveryStatusToString(entity.Status),
|
|
subject = entity.Subject,
|
|
attemptCount = entity.Attempt,
|
|
attempts = Array.Empty<object>(),
|
|
createdAt = entity.CreatedAt,
|
|
sentAt = entity.SentAt,
|
|
failedAt = entity.FailedAt,
|
|
errorMessage = entity.ErrorMessage,
|
|
idempotencyKey = entity.CorrelationId
|
|
};
|
|
|
|
static string DeliveryStatusToString(DeliveryStatus status) => status.ToString().ToLowerInvariant();
|
|
|
|
static DeliveryStatus ToStorageDeliveryStatus(NotifyDeliveryStatus status) => status switch
|
|
{
|
|
NotifyDeliveryStatus.Pending => DeliveryStatus.Pending,
|
|
NotifyDeliveryStatus.Sent => DeliveryStatus.Sent,
|
|
NotifyDeliveryStatus.Failed => DeliveryStatus.Failed,
|
|
NotifyDeliveryStatus.Throttled => DeliveryStatus.Queued,
|
|
NotifyDeliveryStatus.Digested => DeliveryStatus.Delivered,
|
|
NotifyDeliveryStatus.Dropped => DeliveryStatus.Failed,
|
|
_ => DeliveryStatus.Pending
|
|
};
|
|
|
|
static object ToDigestResponse(DigestEntity entity) => new
|
|
{
|
|
id = entity.Id,
|
|
tenantId = entity.TenantId,
|
|
channelId = entity.ChannelId,
|
|
recipient = entity.Recipient,
|
|
digestKey = entity.DigestKey,
|
|
eventCount = entity.EventCount,
|
|
events = string.IsNullOrWhiteSpace(entity.Events) ? new JsonArray() : JsonNode.Parse(entity.Events),
|
|
status = entity.Status,
|
|
collectUntil = entity.CollectUntil,
|
|
sentAt = entity.SentAt,
|
|
createdAt = entity.CreatedAt,
|
|
updatedAt = entity.UpdatedAt
|
|
};
|
|
|
|
static T? TryDeserialize<T>(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
{
|
|
return default;
|
|
}
|
|
|
|
try
|
|
{
|
|
return NotifyCanonicalJsonSerializer.Deserialize<T>(json);
|
|
}
|
|
catch
|
|
{
|
|
return default;
|
|
}
|
|
}
|
|
|
|
static NotifyChannelConfig ToNotifyChannelConfig(ChannelEntity entity)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(entity.Config))
|
|
{
|
|
return NotifyChannelConfig.Create("inline-default");
|
|
}
|
|
|
|
var config = TryDeserialize<NotifyChannelConfig>(entity.Config);
|
|
if (config is not null)
|
|
{
|
|
return config;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(entity.Config);
|
|
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
|
{
|
|
return NotifyChannelConfig.Create(BuildLegacyChannelSecretRef(entity.Id));
|
|
}
|
|
|
|
var root = document.RootElement;
|
|
var secretRef = GetOptionalString(root, "secretRef");
|
|
var target = GetOptionalString(root, "target")
|
|
?? GetOptionalString(root, "channel")
|
|
?? GetOptionalString(root, "recipient");
|
|
var endpoint = GetOptionalString(root, "endpoint")
|
|
?? GetOptionalString(root, "webhookUrl")
|
|
?? GetOptionalString(root, "url");
|
|
var properties = ReadLegacyChannelProperties(root);
|
|
|
|
return NotifyChannelConfig.Create(
|
|
string.IsNullOrWhiteSpace(secretRef) ? BuildLegacyChannelSecretRef(entity.Id) : secretRef,
|
|
target,
|
|
endpoint,
|
|
properties.Count == 0 ? null : properties);
|
|
}
|
|
catch
|
|
{
|
|
return NotifyChannelConfig.Create(BuildLegacyChannelSecretRef(entity.Id));
|
|
}
|
|
}
|
|
|
|
static string BuildLegacyChannelSecretRef(Guid channelId)
|
|
=> $"legacy://notify/channels/{channelId:N}";
|
|
|
|
static string? GetOptionalString(JsonElement element, string propertyName)
|
|
{
|
|
if (!element.TryGetProperty(propertyName, out var property))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return property.ValueKind switch
|
|
{
|
|
JsonValueKind.Null or JsonValueKind.Undefined => null,
|
|
JsonValueKind.String => property.GetString(),
|
|
_ => property.GetRawText()
|
|
};
|
|
}
|
|
|
|
static IReadOnlyList<KeyValuePair<string, string>> ReadLegacyChannelProperties(JsonElement root)
|
|
{
|
|
var properties = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
|
|
if (root.TryGetProperty("properties", out var embeddedProperties) && embeddedProperties.ValueKind == JsonValueKind.Object)
|
|
{
|
|
foreach (var property in embeddedProperties.EnumerateObject())
|
|
{
|
|
properties[property.Name] = GetJsonPropertyValue(property.Value);
|
|
}
|
|
}
|
|
|
|
foreach (var property in root.EnumerateObject())
|
|
{
|
|
if (IsCanonicalChannelConfigField(property.Name))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
properties[property.Name] = GetJsonPropertyValue(property.Value);
|
|
}
|
|
|
|
return properties.ToArray();
|
|
}
|
|
|
|
static bool IsCanonicalChannelConfigField(string propertyName)
|
|
=> propertyName.Equals("secretRef", StringComparison.OrdinalIgnoreCase)
|
|
|| propertyName.Equals("target", StringComparison.OrdinalIgnoreCase)
|
|
|| propertyName.Equals("endpoint", StringComparison.OrdinalIgnoreCase)
|
|
|| propertyName.Equals("properties", StringComparison.OrdinalIgnoreCase)
|
|
|| propertyName.Equals("limits", StringComparison.OrdinalIgnoreCase)
|
|
|| propertyName.Equals("channel", StringComparison.OrdinalIgnoreCase)
|
|
|| propertyName.Equals("recipient", StringComparison.OrdinalIgnoreCase)
|
|
|| propertyName.Equals("webhookUrl", StringComparison.OrdinalIgnoreCase)
|
|
|| propertyName.Equals("url", StringComparison.OrdinalIgnoreCase);
|
|
|
|
static string GetJsonPropertyValue(JsonElement element)
|
|
=> element.ValueKind == JsonValueKind.String
|
|
? element.GetString() ?? string.Empty
|
|
: element.GetRawText();
|
|
|
|
static bool TryResolveTenant(HttpContext context, string tenantHeader, out string tenant, out IResult? error)
|
|
{
|
|
// Delegate to unified StellaOps tenant resolver (claims + canonical headers + legacy headers)
|
|
if (StellaOpsTenantResolver.TryResolveTenantId(context, out var resolvedTenant, out var resolverError))
|
|
{
|
|
tenant = resolvedTenant;
|
|
error = null;
|
|
return true;
|
|
}
|
|
|
|
// Fall back to legacy configurable header for backward compatibility
|
|
if (context.Request.Headers.TryGetValue(tenantHeader, out var header) && !string.IsNullOrWhiteSpace(header))
|
|
{
|
|
tenant = header.ToString().Trim().ToLowerInvariant();
|
|
error = null;
|
|
return true;
|
|
}
|
|
|
|
tenant = string.Empty;
|
|
error = Results.BadRequest(new { error = $"Tenant is required. Provide via stellaops:tenant claim, X-StellaOps-Tenant header, or {tenantHeader} header.", error_code = resolverError ?? "tenant_missing" });
|
|
return false;
|
|
}
|
|
|
|
static string BuildResourceLocation(string basePath, params string[] segments)
|
|
{
|
|
if (segments.Length == 0)
|
|
{
|
|
return basePath;
|
|
}
|
|
|
|
var builder = new StringBuilder(basePath);
|
|
foreach (var segment in segments)
|
|
{
|
|
builder.Append('/');
|
|
builder.Append(Uri.EscapeDataString(segment));
|
|
}
|
|
|
|
return builder.ToString();
|
|
}
|
|
|
|
static IResult JsonResponse<T>(T value, int statusCode = StatusCodes.Status200OK, string? location = null)
|
|
{
|
|
var payload = JsonSerializer.Serialize(value, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
|
return new JsonHttpResult(payload, statusCode, location);
|
|
}
|
|
|
|
static IResult CreatedJson<T>(string location, T value)
|
|
=> JsonResponse(value, StatusCodes.Status201Created, location);
|
|
|
|
static IResult Normalize<TModel>(JsonNode? body, Func<JsonNode, TModel> upgrade)
|
|
{
|
|
if (body is null)
|
|
{
|
|
return Results.BadRequest(new { error = _t("notify.error.request_body_required") });
|
|
}
|
|
|
|
try
|
|
{
|
|
var model = upgrade(body);
|
|
var json = NotifyCanonicalJsonSerializer.Serialize(model);
|
|
return Results.Content(json, "application/json");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
static bool HasScope(ClaimsPrincipal principal, string scope)
|
|
{
|
|
if (principal is null || string.IsNullOrWhiteSpace(scope))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
foreach (var claim in principal.Claims)
|
|
{
|
|
if (!string.Equals(claim.Type, "scope", StringComparison.OrdinalIgnoreCase)
|
|
&& !string.Equals(claim.Type, "http://schemas.microsoft.com/identity/claims/scope", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(claim.Value))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var values = claim.Value.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
|
foreach (var value in values)
|
|
{
|
|
if (string.Equals(value, scope, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
static LogEventLevel MapLogLevel(string configuredLevel)
|
|
{
|
|
return configuredLevel?.ToLowerInvariant() switch
|
|
{
|
|
"verbose" => LogEventLevel.Verbose,
|
|
"debug" => LogEventLevel.Debug,
|
|
"warning" => LogEventLevel.Warning,
|
|
"error" => LogEventLevel.Error,
|
|
"fatal" => LogEventLevel.Fatal,
|
|
_ => LogEventLevel.Information
|
|
};
|
|
}
|
|
|
|
|
|
|