wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TenantIsolationTests.cs
|
||||
// Module: Notifier
|
||||
// Description: Unit tests verifying tenant isolation behaviour of the unified
|
||||
// StellaOpsTenantResolver used by the Notifier WebService.
|
||||
// Exercises claim resolution, header fallbacks, conflict detection,
|
||||
// and full context resolution (actor + project).
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Claims;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant isolation tests for the Notifier module using the unified
|
||||
/// <see cref="StellaOpsTenantResolver"/>. Pure unit tests -- no Postgres,
|
||||
/// no WebApplicationFactory.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TenantIsolationTests
|
||||
{
|
||||
// ---------------------------------------------------------------
|
||||
// 1. Missing tenant returns false with "tenant_missing"
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_MissingTenant_ReturnsFalseWithTenantMissing()
|
||||
{
|
||||
// Arrange -- no claims, no headers
|
||||
var ctx = CreateHttpContext();
|
||||
|
||||
// Act
|
||||
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
resolved.Should().BeFalse("no tenant source is available");
|
||||
tenantId.Should().BeEmpty();
|
||||
error.Should().Be("tenant_missing");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 2. Canonical claim resolves tenant
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_CanonicalClaim_ResolvesTenant()
|
||||
{
|
||||
// Arrange
|
||||
var ctx = CreateHttpContext();
|
||||
ctx.User = PrincipalWithClaims(
|
||||
new Claim(StellaOpsClaimTypes.Tenant, "acme-corp"));
|
||||
|
||||
// Act
|
||||
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
resolved.Should().BeTrue();
|
||||
tenantId.Should().Be("acme-corp");
|
||||
error.Should().BeNull();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 3. Legacy "tid" claim fallback
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_LegacyTidClaim_FallsBack()
|
||||
{
|
||||
// Arrange -- only the legacy "tid" claim, no canonical claim or header
|
||||
var ctx = CreateHttpContext();
|
||||
ctx.User = PrincipalWithClaims(
|
||||
new Claim("tid", "Legacy-Tenant-42"));
|
||||
|
||||
// Act
|
||||
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
resolved.Should().BeTrue("legacy tid claim should be accepted as fallback");
|
||||
tenantId.Should().Be("legacy-tenant-42", "tenant IDs are normalised to lower-case");
|
||||
error.Should().BeNull();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 4. Canonical header resolves tenant
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_CanonicalHeader_ResolvesTenant()
|
||||
{
|
||||
// Arrange -- no claims, only the canonical header
|
||||
var ctx = CreateHttpContext();
|
||||
ctx.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "header-tenant";
|
||||
|
||||
// Act
|
||||
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
resolved.Should().BeTrue();
|
||||
tenantId.Should().Be("header-tenant");
|
||||
error.Should().BeNull();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 5. Full context resolves actor and project
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_FullContext_ResolvesActorAndProject()
|
||||
{
|
||||
// Arrange
|
||||
var ctx = CreateHttpContext();
|
||||
ctx.User = PrincipalWithClaims(
|
||||
new Claim(StellaOpsClaimTypes.Tenant, "acme-corp"),
|
||||
new Claim(StellaOpsClaimTypes.Subject, "user-42"),
|
||||
new Claim(StellaOpsClaimTypes.Project, "project-alpha"));
|
||||
|
||||
// Act
|
||||
var resolved = StellaOpsTenantResolver.TryResolve(ctx, out var tenantContext, out var error);
|
||||
|
||||
// Assert
|
||||
resolved.Should().BeTrue();
|
||||
error.Should().BeNull();
|
||||
tenantContext.Should().NotBeNull();
|
||||
tenantContext!.TenantId.Should().Be("acme-corp");
|
||||
tenantContext.ActorId.Should().Be("user-42");
|
||||
tenantContext.ProjectId.Should().Be("project-alpha");
|
||||
tenantContext.Source.Should().Be(TenantSource.Claim);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 6. Conflicting headers return tenant_conflict
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_ConflictingHeaders_ReturnsTenantConflict()
|
||||
{
|
||||
// Arrange -- canonical and legacy headers with different values
|
||||
var ctx = CreateHttpContext();
|
||||
ctx.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "tenant-a";
|
||||
ctx.Request.Headers["X-Stella-Tenant"] = "tenant-b";
|
||||
|
||||
// Act
|
||||
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
resolved.Should().BeFalse("conflicting headers must be rejected");
|
||||
error.Should().Be("tenant_conflict");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 7. Claim-header mismatch returns tenant_conflict
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_ClaimHeaderMismatch_ReturnsTenantConflict()
|
||||
{
|
||||
// Arrange -- claim says "tenant-claim" but header says "tenant-header"
|
||||
var ctx = CreateHttpContext();
|
||||
ctx.User = PrincipalWithClaims(
|
||||
new Claim(StellaOpsClaimTypes.Tenant, "tenant-claim"));
|
||||
ctx.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "tenant-header";
|
||||
|
||||
// Act
|
||||
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
resolved.Should().BeFalse("claim-header mismatch must be rejected");
|
||||
error.Should().Be("tenant_conflict");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 8. Matching claim and header -- no conflict
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_MatchingClaimAndHeader_NoConflict()
|
||||
{
|
||||
// Arrange -- claim and header agree on the same tenant
|
||||
var ctx = CreateHttpContext();
|
||||
ctx.User = PrincipalWithClaims(
|
||||
new Claim(StellaOpsClaimTypes.Tenant, "same-tenant"));
|
||||
ctx.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "same-tenant";
|
||||
|
||||
// Act
|
||||
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
resolved.Should().BeTrue("matching claim and header should not conflict");
|
||||
tenantId.Should().Be("same-tenant");
|
||||
error.Should().BeNull();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private static DefaultHttpContext CreateHttpContext()
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Response.Body = new MemoryStream();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal PrincipalWithClaims(params Claim[] claims)
|
||||
{
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace StellaOps.Notifier.WebService.Constants;
|
||||
|
||||
/// <summary>
|
||||
/// Named authorization policy constants for Notifier API endpoints.
|
||||
/// These correspond to scopes defined in <see cref="StellaOps.Auth.Abstractions.StellaOpsScopes"/>.
|
||||
/// </summary>
|
||||
public static class NotifierPolicies
|
||||
{
|
||||
/// <summary>
|
||||
/// Read-only access to channels, rules, templates, delivery history, and observability.
|
||||
/// Maps to scope: notify.viewer
|
||||
/// </summary>
|
||||
public const string NotifyViewer = "notify.viewer";
|
||||
|
||||
/// <summary>
|
||||
/// Rule management, channel operations, template authoring, delivery actions, and simulation.
|
||||
/// Maps to scope: notify.operator
|
||||
/// </summary>
|
||||
public const string NotifyOperator = "notify.operator";
|
||||
|
||||
/// <summary>
|
||||
/// Administrative control over security configuration, signing key rotation,
|
||||
/// tenant isolation grants, retention policies, and platform-wide settings.
|
||||
/// Maps to scope: notify.admin
|
||||
/// </summary>
|
||||
public const string NotifyAdmin = "notify.admin";
|
||||
|
||||
/// <summary>
|
||||
/// Escalation-specific actions: starting, escalating, stopping incidents and
|
||||
/// managing escalation policies and on-call schedules.
|
||||
/// Maps to scope: notify.escalate
|
||||
/// </summary>
|
||||
public const string NotifyEscalate = "notify.escalate";
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notifier.WebService.Constants;
|
||||
using StellaOps.Notifier.WebService.Extensions;
|
||||
using StellaOps.Notifier.Worker.Escalation;
|
||||
|
||||
@@ -18,110 +20,153 @@ public static class EscalationEndpoints
|
||||
// Escalation Policies
|
||||
var policies = app.MapGroup("/api/v2/escalation-policies")
|
||||
.WithTags("Escalation Policies")
|
||||
.WithOpenApi();
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
policies.MapGet("/", ListPoliciesAsync)
|
||||
.WithName("ListEscalationPolicies")
|
||||
.WithSummary("List escalation policies");
|
||||
.WithSummary("List escalation policies")
|
||||
.WithDescription("Returns all escalation policies for the tenant. Policies define the escalation levels, targets, and timing used when an incident is unacknowledged.");
|
||||
|
||||
policies.MapGet("/{policyId}", GetPolicyAsync)
|
||||
.WithName("GetEscalationPolicy")
|
||||
.WithSummary("Get an escalation policy");
|
||||
.WithSummary("Get an escalation policy")
|
||||
.WithDescription("Returns a single escalation policy by identifier, including all levels and target configurations.");
|
||||
|
||||
policies.MapPost("/", CreatePolicyAsync)
|
||||
.WithName("CreateEscalationPolicy")
|
||||
.WithSummary("Create an escalation policy");
|
||||
.WithSummary("Create an escalation policy")
|
||||
.WithDescription("Creates a new escalation policy with one or more escalation levels. Each level specifies targets, escalation timeout, and notification mode.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
policies.MapPut("/{policyId}", UpdatePolicyAsync)
|
||||
.WithName("UpdateEscalationPolicy")
|
||||
.WithSummary("Update an escalation policy");
|
||||
.WithSummary("Update an escalation policy")
|
||||
.WithDescription("Updates an existing escalation policy. Changes apply to future escalations; in-flight escalations continue with the previous policy configuration.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
policies.MapDelete("/{policyId}", DeletePolicyAsync)
|
||||
.WithName("DeleteEscalationPolicy")
|
||||
.WithSummary("Delete an escalation policy");
|
||||
.WithSummary("Delete an escalation policy")
|
||||
.WithDescription("Deletes an escalation policy. The policy cannot be deleted if it is referenced by active escalations.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
// On-Call Schedules
|
||||
var schedules = app.MapGroup("/api/v2/oncall-schedules")
|
||||
.WithTags("On-Call Schedules")
|
||||
.WithOpenApi();
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
schedules.MapGet("/", ListSchedulesAsync)
|
||||
.WithName("ListOnCallSchedules")
|
||||
.WithSummary("List on-call schedules");
|
||||
.WithSummary("List on-call schedules")
|
||||
.WithDescription("Returns all on-call rotation schedules for the tenant, including layers, rotation intervals, and enabled state.");
|
||||
|
||||
schedules.MapGet("/{scheduleId}", GetScheduleAsync)
|
||||
.WithName("GetOnCallSchedule")
|
||||
.WithSummary("Get an on-call schedule");
|
||||
.WithSummary("Get an on-call schedule")
|
||||
.WithDescription("Returns a single on-call schedule by identifier, including all rotation layers and user assignments.");
|
||||
|
||||
schedules.MapPost("/", CreateScheduleAsync)
|
||||
.WithName("CreateOnCallSchedule")
|
||||
.WithSummary("Create an on-call schedule");
|
||||
.WithSummary("Create an on-call schedule")
|
||||
.WithDescription("Creates a new on-call rotation schedule with one or more rotation layers defining users, rotation type, and handoff times.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
schedules.MapPut("/{scheduleId}", UpdateScheduleAsync)
|
||||
.WithName("UpdateOnCallSchedule")
|
||||
.WithSummary("Update an on-call schedule");
|
||||
.WithSummary("Update an on-call schedule")
|
||||
.WithDescription("Updates an existing on-call schedule. Current on-call assignments recalculate immediately based on the new configuration.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
schedules.MapDelete("/{scheduleId}", DeleteScheduleAsync)
|
||||
.WithName("DeleteOnCallSchedule")
|
||||
.WithSummary("Delete an on-call schedule");
|
||||
.WithSummary("Delete an on-call schedule")
|
||||
.WithDescription("Deletes an on-call schedule. Escalation policies referencing this schedule will fall back to direct targets.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
schedules.MapGet("/{scheduleId}/oncall", GetCurrentOnCallAsync)
|
||||
.WithName("GetCurrentOnCall")
|
||||
.WithSummary("Get current on-call users");
|
||||
.WithSummary("Get current on-call users")
|
||||
.WithDescription("Returns the users currently on-call for the schedule. Accepts an optional atTime query parameter to evaluate a past or future on-call window.");
|
||||
|
||||
schedules.MapPost("/{scheduleId}/overrides", CreateOverrideAsync)
|
||||
.WithName("CreateOnCallOverride")
|
||||
.WithSummary("Create an on-call override");
|
||||
.WithSummary("Create an on-call override")
|
||||
.WithDescription("Creates a time-bounded override placing a specific user on-call for a schedule, superseding the normal rotation for that window.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
schedules.MapDelete("/{scheduleId}/overrides/{overrideId}", DeleteOverrideAsync)
|
||||
.WithName("DeleteOnCallOverride")
|
||||
.WithSummary("Delete an on-call override");
|
||||
.WithSummary("Delete an on-call override")
|
||||
.WithDescription("Removes an on-call override, restoring the standard rotation for the schedule.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
// Active Escalations
|
||||
var escalations = app.MapGroup("/api/v2/escalations")
|
||||
.WithTags("Escalations")
|
||||
.WithOpenApi();
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
escalations.MapGet("/", ListActiveEscalationsAsync)
|
||||
.WithName("ListActiveEscalations")
|
||||
.WithSummary("List active escalations");
|
||||
.WithSummary("List active escalations")
|
||||
.WithDescription("Returns all currently active escalations for the tenant, including current level, targets notified, and elapsed time.");
|
||||
|
||||
escalations.MapGet("/{incidentId}", GetEscalationStateAsync)
|
||||
.WithName("GetEscalationState")
|
||||
.WithSummary("Get escalation state for an incident");
|
||||
.WithSummary("Get escalation state for an incident")
|
||||
.WithDescription("Returns the current escalation state for a specific incident, including which level is active and when the next escalation is scheduled.");
|
||||
|
||||
escalations.MapPost("/{incidentId}/start", StartEscalationAsync)
|
||||
.WithName("StartEscalation")
|
||||
.WithSummary("Start escalation for an incident");
|
||||
.WithSummary("Start escalation for an incident")
|
||||
.WithDescription("Starts a new escalation for an incident using the specified policy. Returns conflict if an escalation is already active for the incident.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
escalations.MapPost("/{incidentId}/escalate", ManualEscalateAsync)
|
||||
.WithName("ManualEscalate")
|
||||
.WithSummary("Manually escalate to next level");
|
||||
.WithSummary("Manually escalate to next level")
|
||||
.WithDescription("Immediately advances the escalation to the next level without waiting for the automatic timeout. An optional reason is recorded in the escalation audit trail.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
escalations.MapPost("/{incidentId}/stop", StopEscalationAsync)
|
||||
.WithName("StopEscalation")
|
||||
.WithSummary("Stop escalation");
|
||||
.WithSummary("Stop escalation")
|
||||
.WithDescription("Stops an active escalation for an incident. The stop reason is recorded in the audit trail. On-call targets are not notified after stopping.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
// Ack Bridge
|
||||
var ack = app.MapGroup("/api/v2/ack")
|
||||
.WithTags("Acknowledgment")
|
||||
.WithOpenApi();
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator)
|
||||
.RequireTenant();
|
||||
|
||||
ack.MapPost("/", ProcessAckAsync)
|
||||
.WithName("ProcessAck")
|
||||
.WithSummary("Process an acknowledgment");
|
||||
.WithSummary("Process an acknowledgment")
|
||||
.WithDescription("Processes an acknowledgment for an incident from the API. Stops the escalation if one is active and records the acknowledgment in the audit log.");
|
||||
|
||||
ack.MapGet("/", ProcessAckLinkAsync)
|
||||
.WithName("ProcessAckLink")
|
||||
.WithSummary("Process an acknowledgment link");
|
||||
.WithSummary("Process an acknowledgment link")
|
||||
.WithDescription("Processes an acknowledgment via a signed one-time link token (e.g., from an email notification). The token is validated for expiry and replay before acknowledgment is recorded.");
|
||||
|
||||
ack.MapPost("/webhook/pagerduty", ProcessPagerDutyWebhookAsync)
|
||||
.WithName("PagerDutyWebhook")
|
||||
.WithSummary("Process PagerDuty webhook");
|
||||
.WithSummary("Process PagerDuty webhook")
|
||||
.WithDescription("Receives and processes inbound acknowledgment webhooks from PagerDuty. No authentication is required; the request is validated using the PagerDuty webhook signature.")
|
||||
.AllowAnonymous();
|
||||
|
||||
ack.MapPost("/webhook/opsgenie", ProcessOpsGenieWebhookAsync)
|
||||
.WithName("OpsGenieWebhook")
|
||||
.WithSummary("Process OpsGenie webhook");
|
||||
.WithSummary("Process OpsGenie webhook")
|
||||
.WithDescription("Receives and processes inbound acknowledgment webhooks from OpsGenie. No authentication is required; the request is validated using the OpsGenie webhook signature.")
|
||||
.AllowAnonymous();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notifier.WebService.Constants;
|
||||
using StellaOps.Notifier.WebService.Extensions;
|
||||
using StellaOps.Notifier.Worker.Fallback;
|
||||
using StellaOps.Notify.Models;
|
||||
@@ -20,7 +22,9 @@ public static class FallbackEndpoints
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v2/fallback")
|
||||
.WithTags("Fallback")
|
||||
.WithOpenApi();
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
// Get fallback statistics
|
||||
group.MapGet("/statistics", async (
|
||||
@@ -51,7 +55,8 @@ public static class FallbackEndpoints
|
||||
});
|
||||
})
|
||||
.WithName("GetFallbackStatistics")
|
||||
.WithSummary("Gets fallback handling statistics for a tenant");
|
||||
.WithSummary("Gets fallback handling statistics for a tenant")
|
||||
.WithDescription("Returns aggregate delivery statistics for the tenant including primary success rate, fallback attempt count, fallback success rate, and per-channel failure breakdown over the specified window.");
|
||||
|
||||
// Get fallback chain for a channel
|
||||
group.MapGet("/chains/{channelType}", async (
|
||||
@@ -73,7 +78,8 @@ public static class FallbackEndpoints
|
||||
});
|
||||
})
|
||||
.WithName("GetFallbackChain")
|
||||
.WithSummary("Gets the fallback chain for a channel type");
|
||||
.WithSummary("Gets the fallback chain for a channel type")
|
||||
.WithDescription("Returns the ordered list of fallback channel types that will be tried when the primary channel fails. If no custom chain is configured, the system default is returned.");
|
||||
|
||||
// Set fallback chain for a channel
|
||||
group.MapPut("/chains/{channelType}", async (
|
||||
@@ -102,7 +108,9 @@ public static class FallbackEndpoints
|
||||
});
|
||||
})
|
||||
.WithName("SetFallbackChain")
|
||||
.WithSummary("Sets a custom fallback chain for a channel type");
|
||||
.WithSummary("Sets a custom fallback chain for a channel type")
|
||||
.WithDescription("Creates or replaces the fallback chain for a primary channel type. The chain must reference valid channel types; invalid entries are silently filtered out.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
// Test fallback resolution
|
||||
group.MapPost("/test", async (
|
||||
@@ -150,7 +158,9 @@ public static class FallbackEndpoints
|
||||
});
|
||||
})
|
||||
.WithName("TestFallback")
|
||||
.WithSummary("Tests fallback resolution without affecting real deliveries");
|
||||
.WithSummary("Tests fallback resolution without affecting real deliveries")
|
||||
.WithDescription("Simulates a channel failure for the specified channel type and returns which fallback channel would be selected next. The simulated delivery state is cleaned up after the test.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
// Clear delivery state
|
||||
group.MapDelete("/deliveries/{deliveryId}", async (
|
||||
@@ -166,7 +176,9 @@ public static class FallbackEndpoints
|
||||
return Results.Ok(new { message = $"Delivery state for '{deliveryId}' cleared" });
|
||||
})
|
||||
.WithName("ClearDeliveryFallbackState")
|
||||
.WithSummary("Clears fallback state for a specific delivery");
|
||||
.WithSummary("Clears fallback state for a specific delivery")
|
||||
.WithDescription("Removes all in-memory fallback tracking state for a delivery ID. Use this to reset a stuck delivery that has exhausted its fallback chain without entering a terminal status.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notifier.WebService.Constants;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Text.Json;
|
||||
@@ -17,23 +19,30 @@ public static class IncidentEndpoints
|
||||
public static IEndpointRouteBuilder MapIncidentEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/incidents")
|
||||
.WithTags("Incidents");
|
||||
.WithTags("Incidents")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/", ListIncidentsAsync)
|
||||
.WithName("ListIncidents")
|
||||
.WithSummary("Lists notification incidents (deliveries)");
|
||||
.WithSummary("Lists notification incidents (deliveries)")
|
||||
.WithDescription("Returns a paginated list of notification deliveries for the tenant. Supports filtering by status, event kind, rule ID, time range, and cursor-based pagination.");
|
||||
|
||||
group.MapGet("/{deliveryId}", GetIncidentAsync)
|
||||
.WithName("GetIncident")
|
||||
.WithSummary("Gets an incident by delivery ID");
|
||||
.WithSummary("Gets an incident by delivery ID")
|
||||
.WithDescription("Returns a single delivery record by its identifier, including status, attempt history, and metadata.");
|
||||
|
||||
group.MapPost("/{deliveryId}/ack", AcknowledgeIncidentAsync)
|
||||
.WithName("AcknowledgeIncident")
|
||||
.WithSummary("Acknowledges an incident");
|
||||
.WithSummary("Acknowledges an incident")
|
||||
.WithDescription("Acknowledges or resolves a delivery incident, updating its status and appending an audit entry. Accepts an optional resolution type (resolved, dismissed) and comment.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapGet("/stats", GetIncidentStatsAsync)
|
||||
.WithName("GetIncidentStats")
|
||||
.WithSummary("Gets incident statistics");
|
||||
.WithSummary("Gets incident statistics")
|
||||
.WithDescription("Returns aggregate delivery counts for the tenant, broken down by status, event kind, and rule ID.");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notifier.WebService.Constants;
|
||||
using StellaOps.Notifier.WebService.Extensions;
|
||||
using StellaOps.Notifier.Worker.Localization;
|
||||
|
||||
@@ -19,7 +21,9 @@ public static class LocalizationEndpoints
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v2/localization")
|
||||
.WithTags("Localization")
|
||||
.WithOpenApi();
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
// List bundles
|
||||
group.MapGet("/bundles", async (
|
||||
@@ -52,7 +56,8 @@ public static class LocalizationEndpoints
|
||||
});
|
||||
})
|
||||
.WithName("ListLocalizationBundles")
|
||||
.WithSummary("Lists all localization bundles for a tenant");
|
||||
.WithSummary("Lists all localization bundles for a tenant")
|
||||
.WithDescription("Returns all localization bundles for the tenant, including bundle ID, locale, namespace, string count, priority, and enabled state.");
|
||||
|
||||
// Get supported locales
|
||||
group.MapGet("/locales", async (
|
||||
@@ -72,7 +77,8 @@ public static class LocalizationEndpoints
|
||||
});
|
||||
})
|
||||
.WithName("GetSupportedLocales")
|
||||
.WithSummary("Gets all supported locales for a tenant");
|
||||
.WithSummary("Gets all supported locales for a tenant")
|
||||
.WithDescription("Returns the distinct set of locale codes for which at least one enabled localization bundle exists for the tenant.");
|
||||
|
||||
// Get bundle contents
|
||||
group.MapGet("/bundles/{locale}", async (
|
||||
@@ -94,7 +100,8 @@ public static class LocalizationEndpoints
|
||||
});
|
||||
})
|
||||
.WithName("GetLocalizationBundle")
|
||||
.WithSummary("Gets all localized strings for a locale");
|
||||
.WithSummary("Gets all localized strings for a locale")
|
||||
.WithDescription("Returns the merged set of all localized strings for the specified locale, combining bundles in priority order.");
|
||||
|
||||
// Get single string
|
||||
group.MapGet("/strings/{key}", async (
|
||||
@@ -118,7 +125,8 @@ public static class LocalizationEndpoints
|
||||
});
|
||||
})
|
||||
.WithName("GetLocalizedString")
|
||||
.WithSummary("Gets a single localized string");
|
||||
.WithSummary("Gets a single localized string")
|
||||
.WithDescription("Resolves a single localized string by key and locale, falling back to en-US if the key is absent in the requested locale.");
|
||||
|
||||
// Format string with parameters
|
||||
group.MapPost("/strings/{key}/format", async (
|
||||
@@ -144,7 +152,8 @@ public static class LocalizationEndpoints
|
||||
});
|
||||
})
|
||||
.WithName("FormatLocalizedString")
|
||||
.WithSummary("Gets a localized string with parameter substitution");
|
||||
.WithSummary("Gets a localized string with parameter substitution")
|
||||
.WithDescription("Resolves a localized string and applies named parameter substitution using the provided parameters dictionary. Returns the formatted string and the effective locale used.");
|
||||
|
||||
// Create/update bundle
|
||||
group.MapPut("/bundles", async (
|
||||
@@ -189,7 +198,9 @@ public static class LocalizationEndpoints
|
||||
});
|
||||
})
|
||||
.WithName("UpsertLocalizationBundle")
|
||||
.WithSummary("Creates or updates a localization bundle");
|
||||
.WithSummary("Creates or updates a localization bundle")
|
||||
.WithDescription("Creates a new localization bundle or replaces an existing one for the given locale and namespace. Returns 201 on creation or 200 on update.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
// Delete bundle
|
||||
group.MapDelete("/bundles/{bundleId}", async (
|
||||
@@ -211,7 +222,9 @@ public static class LocalizationEndpoints
|
||||
return Results.Ok(new { message = $"Bundle '{bundleId}' deleted successfully" });
|
||||
})
|
||||
.WithName("DeleteLocalizationBundle")
|
||||
.WithSummary("Deletes a localization bundle");
|
||||
.WithSummary("Deletes a localization bundle")
|
||||
.WithDescription("Permanently removes a localization bundle by bundle ID. Strings in the deleted bundle will no longer be resolved; other bundles for the same locale continue to function.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
// Validate bundle
|
||||
group.MapPost("/bundles/validate", (
|
||||
@@ -243,7 +256,8 @@ public static class LocalizationEndpoints
|
||||
});
|
||||
})
|
||||
.WithName("ValidateLocalizationBundle")
|
||||
.WithSummary("Validates a localization bundle without saving");
|
||||
.WithSummary("Validates a localization bundle without saving")
|
||||
.WithDescription("Validates a localization bundle for structural correctness, required fields, and locale code format without persisting it. Returns isValid, errors, and warnings.");
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notifier.WebService.Constants;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
using StellaOps.Notifier.WebService.Extensions;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notifier.Worker.Dispatch;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Notifier.Worker.Templates;
|
||||
@@ -26,7 +28,9 @@ public static class NotifyApiEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/notify")
|
||||
.WithTags("Notify")
|
||||
.WithOpenApi();
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
// Rules CRUD
|
||||
MapRulesEndpoints(group);
|
||||
@@ -57,7 +61,8 @@ public static class NotifyApiEndpoints
|
||||
var response = rules.Select(MapRuleToResponse).ToList();
|
||||
|
||||
return Results.Ok(response);
|
||||
});
|
||||
})
|
||||
.WithDescription("Returns all alert routing rules for the tenant. Rules define which events trigger notifications, which channels receive them, and any throttle or digest settings applied.");
|
||||
|
||||
group.MapGet("/rules/{ruleId}", async (
|
||||
HttpContext context,
|
||||
@@ -78,7 +83,8 @@ public static class NotifyApiEndpoints
|
||||
}
|
||||
|
||||
return Results.Ok(MapRuleToResponse(rule));
|
||||
});
|
||||
})
|
||||
.WithDescription("Retrieves a single alert routing rule by its identifier. Returns match criteria, actions, throttle settings, and audit metadata.");
|
||||
|
||||
group.MapPost("/rules", async (
|
||||
HttpContext context,
|
||||
@@ -108,7 +114,9 @@ public static class NotifyApiEndpoints
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.Created($"/api/v2/notify/rules/{rule.RuleId}", MapRuleToResponse(rule));
|
||||
});
|
||||
})
|
||||
.WithDescription("Creates a new alert routing rule. The rule specifies event match criteria (kinds, namespaces, severities) and the notification actions to execute. An audit entry is written on creation.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPut("/rules/{ruleId}", async (
|
||||
HttpContext context,
|
||||
@@ -145,7 +153,9 @@ public static class NotifyApiEndpoints
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.Ok(MapRuleToResponse(updated));
|
||||
});
|
||||
})
|
||||
.WithDescription("Updates an existing alert routing rule. Only the provided fields are changed; match criteria, actions, throttle settings, and labels are merged. An audit entry is written on update.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapDelete("/rules/{ruleId}", async (
|
||||
HttpContext context,
|
||||
@@ -176,7 +186,9 @@ public static class NotifyApiEndpoints
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
})
|
||||
.WithDescription("Permanently removes an alert routing rule. Future events will no longer be matched against this rule. An audit entry is written on deletion.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
}
|
||||
|
||||
private static void MapTemplatesEndpoints(RouteGroupBuilder group)
|
||||
@@ -214,7 +226,8 @@ public static class NotifyApiEndpoints
|
||||
var response = templates.Select(MapTemplateToResponse).ToList();
|
||||
|
||||
return Results.Ok(response);
|
||||
});
|
||||
})
|
||||
.WithDescription("Lists all notification templates for the tenant, with optional filtering by key prefix, channel type, and locale. Templates define the rendered message body used by notification rules.");
|
||||
|
||||
group.MapGet("/templates/{templateId}", async (
|
||||
HttpContext context,
|
||||
@@ -235,7 +248,8 @@ public static class NotifyApiEndpoints
|
||||
}
|
||||
|
||||
return Results.Ok(MapTemplateToResponse(template));
|
||||
});
|
||||
})
|
||||
.WithDescription("Retrieves a single notification template by its identifier. Returns the template body, channel type, locale, render mode, and audit metadata.");
|
||||
|
||||
group.MapPost("/templates", async (
|
||||
HttpContext context,
|
||||
@@ -294,7 +308,9 @@ public static class NotifyApiEndpoints
|
||||
return result.IsNew
|
||||
? Results.Created($"/api/v2/notify/templates/{request.TemplateId}", MapTemplateToResponse(created!))
|
||||
: Results.Ok(MapTemplateToResponse(created!));
|
||||
});
|
||||
})
|
||||
.WithDescription("Creates or updates a notification template. The template body supports Scriban syntax with access to event payload fields. Validation is performed before persisting; an error is returned for invalid syntax.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapDelete("/templates/{templateId}", async (
|
||||
HttpContext context,
|
||||
@@ -317,7 +333,9 @@ public static class NotifyApiEndpoints
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
})
|
||||
.WithDescription("Permanently removes a notification template. Rules referencing this template will fall back to channel defaults on the next delivery. An audit entry is written on deletion.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPost("/templates/preview", async (
|
||||
HttpContext context,
|
||||
@@ -393,7 +411,9 @@ public static class NotifyApiEndpoints
|
||||
Format = rendered.Format.ToString(),
|
||||
Warnings = warnings
|
||||
});
|
||||
});
|
||||
})
|
||||
.WithDescription("Renders a template against a sample event payload without sending any notification. Accepts either an existing templateId or an inline templateBody. Returns the rendered body, subject, and any template warnings.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPost("/templates/validate", (
|
||||
HttpContext context,
|
||||
@@ -413,7 +433,8 @@ public static class NotifyApiEndpoints
|
||||
errors = result.Errors,
|
||||
warnings = result.Warnings
|
||||
});
|
||||
});
|
||||
})
|
||||
.WithDescription("Validates a template body for syntax correctness without persisting it. Returns isValid, a list of errors, and any non-fatal warnings.");
|
||||
}
|
||||
|
||||
private static void MapIncidentsEndpoints(RouteGroupBuilder group)
|
||||
@@ -465,7 +486,8 @@ public static class NotifyApiEndpoints
|
||||
TotalCount = incidents.Count,
|
||||
NextCursor = queryResult.ContinuationToken
|
||||
});
|
||||
});
|
||||
})
|
||||
.WithDescription("Returns a paginated list of notification incidents for the tenant, grouped by event ID. Supports filtering by status, event kind prefix, time range, and cursor-based pagination.");
|
||||
|
||||
group.MapPost("/incidents/{incidentId}/ack", async (
|
||||
HttpContext context,
|
||||
@@ -489,7 +511,9 @@ public static class NotifyApiEndpoints
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
})
|
||||
.WithDescription("Acknowledges an incident, recording the actor and an optional comment in the audit log. Does not stop an active escalation; use the escalation stop endpoint for that.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPost("/incidents/{incidentId}/resolve", async (
|
||||
HttpContext context,
|
||||
@@ -514,7 +538,9 @@ public static class NotifyApiEndpoints
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
})
|
||||
.WithDescription("Marks an incident as resolved, recording the actor, resolution reason, and optional comment in the audit log. Subsequent notifications for this event kind will continue to be processed normally.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notifier.WebService.Constants;
|
||||
using StellaOps.Notifier.Worker.Observability;
|
||||
using StellaOps.Notifier.Worker.Retention;
|
||||
using System.Linq;
|
||||
@@ -20,95 +21,126 @@ public static class ObservabilityEndpoints
|
||||
public static IEndpointRouteBuilder MapObservabilityEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v1/observability")
|
||||
.WithTags("Observability");
|
||||
.WithTags("Observability")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer);
|
||||
|
||||
// Metrics endpoints
|
||||
group.MapGet("/metrics", GetMetricsSnapshot)
|
||||
.WithName("GetMetricsSnapshot")
|
||||
.WithSummary("Gets current metrics snapshot");
|
||||
.WithSummary("Gets current metrics snapshot")
|
||||
.WithDescription("Returns a snapshot of current Notifier service metrics across all tenants, including dispatch rates, error counts, and channel health.");
|
||||
|
||||
group.MapGet("/metrics/{tenantId}", GetTenantMetrics)
|
||||
.WithName("GetTenantMetrics")
|
||||
.WithSummary("Gets metrics for a specific tenant");
|
||||
.WithSummary("Gets metrics for a specific tenant")
|
||||
.WithDescription("Returns a metrics snapshot scoped to a specific tenant, including per-channel delivery rates and recent error totals.");
|
||||
|
||||
// Dead letter endpoints
|
||||
group.MapGet("/dead-letters/{tenantId}", GetDeadLetters)
|
||||
.WithName("GetDeadLetters")
|
||||
.WithSummary("Lists dead letter entries for a tenant");
|
||||
.WithSummary("Lists dead letter entries for a tenant")
|
||||
.WithDescription("Returns paginated dead letter queue entries for the tenant. Dead letters are deliveries that exhausted all retry and fallback attempts.");
|
||||
|
||||
group.MapGet("/dead-letters/{tenantId}/{entryId}", GetDeadLetterEntry)
|
||||
.WithName("GetDeadLetterEntry")
|
||||
.WithSummary("Gets a specific dead letter entry");
|
||||
.WithSummary("Gets a specific dead letter entry")
|
||||
.WithDescription("Returns a single dead letter entry by its identifier, including the original payload, error reason, and all previous attempt details.");
|
||||
|
||||
group.MapPost("/dead-letters/{tenantId}/{entryId}/retry", RetryDeadLetter)
|
||||
.WithName("RetryDeadLetter")
|
||||
.WithSummary("Retries a dead letter entry");
|
||||
.WithSummary("Retries a dead letter entry")
|
||||
.WithDescription("Re-enqueues a dead letter delivery for reprocessing. The entry is removed from the dead letter queue on success.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPost("/dead-letters/{tenantId}/{entryId}/discard", DiscardDeadLetter)
|
||||
.WithName("DiscardDeadLetter")
|
||||
.WithSummary("Discards a dead letter entry");
|
||||
.WithSummary("Discards a dead letter entry")
|
||||
.WithDescription("Permanently discards a dead letter entry with an optional reason. The entry is removed from the dead letter queue and an audit record is written.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapGet("/dead-letters/{tenantId}/stats", GetDeadLetterStats)
|
||||
.WithName("GetDeadLetterStats")
|
||||
.WithSummary("Gets dead letter statistics");
|
||||
.WithSummary("Gets dead letter statistics")
|
||||
.WithDescription("Returns aggregate dead letter statistics for the tenant, including total count, by-channel breakdown, and average age of entries.");
|
||||
|
||||
group.MapDelete("/dead-letters/{tenantId}/purge", PurgeDeadLetters)
|
||||
.WithName("PurgeDeadLetters")
|
||||
.WithSummary("Purges old dead letter entries");
|
||||
.WithSummary("Purges old dead letter entries")
|
||||
.WithDescription("Removes dead letter entries older than the specified number of days. Returns the count of purged entries.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
|
||||
|
||||
// Chaos testing endpoints
|
||||
group.MapGet("/chaos/experiments", ListChaosExperiments)
|
||||
.WithName("ListChaosExperiments")
|
||||
.WithSummary("Lists chaos experiments");
|
||||
.WithSummary("Lists chaos experiments")
|
||||
.WithDescription("Returns all chaos experiments, optionally filtered by status. Chaos experiments inject controlled failures to verify Notifier resilience.");
|
||||
|
||||
group.MapGet("/chaos/experiments/{experimentId}", GetChaosExperiment)
|
||||
.WithName("GetChaosExperiment")
|
||||
.WithSummary("Gets a chaos experiment");
|
||||
.WithSummary("Gets a chaos experiment")
|
||||
.WithDescription("Returns the configuration and current state of a single chaos experiment by its identifier.");
|
||||
|
||||
group.MapPost("/chaos/experiments", StartChaosExperiment)
|
||||
.WithName("StartChaosExperiment")
|
||||
.WithSummary("Starts a new chaos experiment");
|
||||
.WithSummary("Starts a new chaos experiment")
|
||||
.WithDescription("Starts a chaos experiment that injects faults into the notification pipeline. Only one experiment per fault type may run concurrently.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
|
||||
|
||||
group.MapPost("/chaos/experiments/{experimentId}/stop", StopChaosExperiment)
|
||||
.WithName("StopChaosExperiment")
|
||||
.WithSummary("Stops a running chaos experiment");
|
||||
.WithSummary("Stops a running chaos experiment")
|
||||
.WithDescription("Stops a running chaos experiment and removes its fault injection. Normal notification delivery resumes immediately.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
|
||||
|
||||
group.MapGet("/chaos/experiments/{experimentId}/results", GetChaosResults)
|
||||
.WithName("GetChaosResults")
|
||||
.WithSummary("Gets chaos experiment results");
|
||||
.WithSummary("Gets chaos experiment results")
|
||||
.WithDescription("Returns the collected results of a chaos experiment, including injected failure counts, observed retry behavior, and outcome summary.");
|
||||
|
||||
// Retention policy endpoints
|
||||
group.MapGet("/retention/policies", ListRetentionPolicies)
|
||||
.WithName("ListRetentionPolicies")
|
||||
.WithSummary("Lists retention policies");
|
||||
.WithSummary("Lists retention policies")
|
||||
.WithDescription("Returns the active retention policies for the Notifier service, including delivery record TTLs and dead letter purge windows.");
|
||||
|
||||
group.MapGet("/retention/policies/{policyId}", GetRetentionPolicy)
|
||||
.WithName("GetRetentionPolicy")
|
||||
.WithSummary("Gets a retention policy");
|
||||
.WithSummary("Gets a retention policy")
|
||||
.WithDescription("Returns a single retention policy by its identifier.");
|
||||
|
||||
group.MapPost("/retention/policies", CreateRetentionPolicy)
|
||||
.WithName("CreateRetentionPolicy")
|
||||
.WithSummary("Creates a retention policy");
|
||||
.WithSummary("Creates a retention policy")
|
||||
.WithDescription("Creates a new retention policy. Returns conflict if a policy with the same ID already exists.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
|
||||
|
||||
group.MapPut("/retention/policies/{policyId}", UpdateRetentionPolicy)
|
||||
.WithName("UpdateRetentionPolicy")
|
||||
.WithSummary("Updates a retention policy");
|
||||
.WithSummary("Updates a retention policy")
|
||||
.WithDescription("Updates an existing retention policy. Changes take effect on the next scheduled or manually triggered retention execution.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
|
||||
|
||||
group.MapDelete("/retention/policies/{policyId}", DeleteRetentionPolicy)
|
||||
.WithName("DeleteRetentionPolicy")
|
||||
.WithSummary("Deletes a retention policy");
|
||||
.WithSummary("Deletes a retention policy")
|
||||
.WithDescription("Deletes a retention policy, reverting the associated data type to the system default retention window.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
|
||||
|
||||
group.MapPost("/retention/execute", ExecuteRetention)
|
||||
.WithName("ExecuteRetention")
|
||||
.WithSummary("Executes retention policies");
|
||||
.WithSummary("Executes retention policies")
|
||||
.WithDescription("Immediately triggers retention cleanup for the specified policy or all policies. Returns the count of records deleted.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
|
||||
|
||||
group.MapGet("/retention/policies/{policyId}/preview", PreviewRetention)
|
||||
.WithName("PreviewRetention")
|
||||
.WithSummary("Previews retention policy effects");
|
||||
.WithSummary("Previews retention policy effects")
|
||||
.WithDescription("Returns the count and identifiers of records that would be deleted if the retention policy were executed now, without deleting anything.");
|
||||
|
||||
group.MapGet("/retention/policies/{policyId}/history", GetRetentionHistory)
|
||||
.WithName("GetRetentionHistory")
|
||||
.WithSummary("Gets retention execution history");
|
||||
.WithSummary("Gets retention execution history")
|
||||
.WithDescription("Returns the most recent retention execution records for the policy, including run time, records deleted, and any errors encountered.");
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notifier.WebService.Constants;
|
||||
using StellaOps.Notifier.WebService.Extensions;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
@@ -17,32 +19,36 @@ public static class OperatorOverrideEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/overrides")
|
||||
.WithTags("Overrides")
|
||||
.WithOpenApi();
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/", ListOverridesAsync)
|
||||
.WithName("ListOperatorOverrides")
|
||||
.WithSummary("List active operator overrides")
|
||||
.WithDescription("Returns all active operator overrides for the tenant.");
|
||||
.WithDescription("Returns all currently active operator overrides for the tenant, including type (quiet-hours, throttle, maintenance), expiry, and usage counts.");
|
||||
|
||||
group.MapGet("/{overrideId}", GetOverrideAsync)
|
||||
.WithName("GetOperatorOverride")
|
||||
.WithSummary("Get an operator override")
|
||||
.WithDescription("Returns a specific operator override by ID.");
|
||||
.WithDescription("Returns a single operator override by its identifier, including status, remaining duration, and event kind filters.");
|
||||
|
||||
group.MapPost("/", CreateOverrideAsync)
|
||||
.WithName("CreateOperatorOverride")
|
||||
.WithSummary("Create an operator override")
|
||||
.WithDescription("Creates a new operator override to bypass quiet hours and/or throttling.");
|
||||
.WithDescription("Creates a time-bounded operator override that bypasses quiet hours, throttling, or maintenance windows for the specified event kinds. Requires a reason and duration in minutes.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPost("/{overrideId}/revoke", RevokeOverrideAsync)
|
||||
.WithName("RevokeOperatorOverride")
|
||||
.WithSummary("Revoke an operator override")
|
||||
.WithDescription("Revokes an active operator override.");
|
||||
.WithDescription("Immediately revokes an active operator override before its natural expiry. The revocation reason and actor are recorded in the override history.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPost("/check", CheckOverrideAsync)
|
||||
.WithName("CheckOperatorOverride")
|
||||
.WithSummary("Check for applicable override")
|
||||
.WithDescription("Checks if an override applies to the given event criteria.");
|
||||
.WithDescription("Checks whether any active override applies to a given event kind and optional correlation key. Returns the matched override details and the bypass types it grants.");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notifier.WebService.Constants;
|
||||
using StellaOps.Notifier.WebService.Extensions;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
@@ -17,37 +19,42 @@ public static class QuietHoursEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/quiet-hours")
|
||||
.WithTags("QuietHours")
|
||||
.WithOpenApi();
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/calendars", ListCalendarsAsync)
|
||||
.WithName("ListQuietHoursCalendars")
|
||||
.WithSummary("List all quiet hours calendars")
|
||||
.WithDescription("Returns all quiet hours calendars for the tenant.");
|
||||
.WithDescription("Returns all quiet hours calendars for the tenant, including schedules, enabled state, priority, and event kind filters.");
|
||||
|
||||
group.MapGet("/calendars/{calendarId}", GetCalendarAsync)
|
||||
.WithName("GetQuietHoursCalendar")
|
||||
.WithSummary("Get a quiet hours calendar")
|
||||
.WithDescription("Returns a specific quiet hours calendar by ID.");
|
||||
.WithDescription("Returns a single quiet hours calendar by its identifier, including all schedule entries and timezone settings.");
|
||||
|
||||
group.MapPost("/calendars", CreateCalendarAsync)
|
||||
.WithName("CreateQuietHoursCalendar")
|
||||
.WithSummary("Create a quiet hours calendar")
|
||||
.WithDescription("Creates a new quiet hours calendar with schedules.");
|
||||
.WithDescription("Creates a new quiet hours calendar defining time windows during which notifications are suppressed. At least one schedule entry is required.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPut("/calendars/{calendarId}", UpdateCalendarAsync)
|
||||
.WithName("UpdateQuietHoursCalendar")
|
||||
.WithSummary("Update a quiet hours calendar")
|
||||
.WithDescription("Updates an existing quiet hours calendar.");
|
||||
.WithDescription("Updates an existing quiet hours calendar. Changes take effect immediately for all subsequent notification evaluations.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapDelete("/calendars/{calendarId}", DeleteCalendarAsync)
|
||||
.WithName("DeleteQuietHoursCalendar")
|
||||
.WithSummary("Delete a quiet hours calendar")
|
||||
.WithDescription("Deletes a quiet hours calendar.");
|
||||
.WithDescription("Permanently removes a quiet hours calendar. Notifications that would have been suppressed by this calendar will resume delivering normally.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPost("/evaluate", EvaluateAsync)
|
||||
.WithName("EvaluateQuietHours")
|
||||
.WithSummary("Evaluate quiet hours")
|
||||
.WithDescription("Checks if quiet hours are currently active for an event kind.");
|
||||
.WithDescription("Checks whether quiet hours are currently active for the specified event kind. Returns the matched calendar, schedule name, and time when quiet hours end if active.");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notifier.WebService.Constants;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Notify.Models;
|
||||
@@ -19,27 +21,37 @@ public static class RuleEndpoints
|
||||
public static IEndpointRouteBuilder MapRuleEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/rules")
|
||||
.WithTags("Rules");
|
||||
.WithTags("Rules")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/", ListRulesAsync)
|
||||
.WithName("ListRules")
|
||||
.WithSummary("Lists all rules for a tenant");
|
||||
.WithSummary("Lists all rules for a tenant")
|
||||
.WithDescription("Returns all alert routing rules for the tenant with optional filtering by enabled state, name prefix, and limit. Rules define event match criteria and the notification actions to execute.");
|
||||
|
||||
group.MapGet("/{ruleId}", GetRuleAsync)
|
||||
.WithName("GetRule")
|
||||
.WithSummary("Gets a rule by ID");
|
||||
.WithSummary("Gets a rule by ID")
|
||||
.WithDescription("Returns a single alert routing rule by its identifier, including match criteria, actions, throttle settings, labels, and audit metadata.");
|
||||
|
||||
group.MapPost("/", CreateRuleAsync)
|
||||
.WithName("CreateRule")
|
||||
.WithSummary("Creates a new rule");
|
||||
.WithSummary("Creates a new rule")
|
||||
.WithDescription("Creates a new alert routing rule. Returns conflict if a rule with the same ID already exists. An audit entry is written on creation.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPut("/{ruleId}", UpdateRuleAsync)
|
||||
.WithName("UpdateRule")
|
||||
.WithSummary("Updates an existing rule");
|
||||
.WithSummary("Updates an existing rule")
|
||||
.WithDescription("Updates an existing alert routing rule. Provided fields are merged with the existing rule. An audit entry is written on update.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapDelete("/{ruleId}", DeleteRuleAsync)
|
||||
.WithName("DeleteRule")
|
||||
.WithSummary("Deletes a rule");
|
||||
.WithSummary("Deletes a rule")
|
||||
.WithDescription("Permanently removes an alert routing rule. Future events will no longer be matched against this rule. An audit entry is written on deletion.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notifier.WebService.Constants;
|
||||
using StellaOps.Notifier.Worker.Security;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
@@ -11,75 +13,77 @@ public static class SecurityEndpoints
|
||||
public static IEndpointRouteBuilder MapSecurityEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v2/security")
|
||||
.WithTags("Security");
|
||||
.WithTags("Security")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyAdmin)
|
||||
.RequireTenant();
|
||||
|
||||
// Signing endpoints
|
||||
group.MapPost("/tokens/sign", SignTokenAsync)
|
||||
.WithName("SignToken")
|
||||
.WithDescription("Signs a payload and returns a token.");
|
||||
.WithDescription("Signs a payload and returns a HMAC-signed acknowledgment token. The token encodes purpose, subject, tenant, and expiry claims.");
|
||||
|
||||
group.MapPost("/tokens/verify", VerifyTokenAsync)
|
||||
.WithName("VerifyToken")
|
||||
.WithDescription("Verifies a token and returns the payload if valid.");
|
||||
.WithDescription("Verifies a signed token and returns the decoded payload if valid. Returns an error if the token is expired, tampered, or issued by a rotated key.");
|
||||
|
||||
group.MapGet("/tokens/{token}/info", GetTokenInfo)
|
||||
.WithName("GetTokenInfo")
|
||||
.WithDescription("Gets information about a token without verification.");
|
||||
.WithDescription("Decodes and returns structural information about a token without performing cryptographic verification. Useful for debugging expired or unknown tokens.");
|
||||
|
||||
group.MapPost("/keys/rotate", RotateKeyAsync)
|
||||
.WithName("RotateSigningKey")
|
||||
.WithDescription("Rotates the signing key.");
|
||||
.WithDescription("Rotates the active signing key. Previously signed tokens remain verifiable during the overlap window. Old keys are retired after the configured grace period.");
|
||||
|
||||
// Webhook security endpoints
|
||||
group.MapPost("/webhooks", RegisterWebhookConfigAsync)
|
||||
.WithName("RegisterWebhookConfig")
|
||||
.WithDescription("Registers webhook security configuration.");
|
||||
.WithDescription("Registers or replaces the webhook security configuration for a channel, including the shared secret and allowed IP ranges.");
|
||||
|
||||
group.MapGet("/webhooks/{tenantId}/{channelId}", GetWebhookConfigAsync)
|
||||
.WithName("GetWebhookConfig")
|
||||
.WithDescription("Gets webhook security configuration.");
|
||||
.WithDescription("Returns the webhook security configuration for a tenant and channel. The secret is not included in the response.");
|
||||
|
||||
group.MapPost("/webhooks/validate", ValidateWebhookAsync)
|
||||
.WithName("ValidateWebhook")
|
||||
.WithDescription("Validates a webhook request.");
|
||||
.WithDescription("Validates an inbound webhook request against its registered security configuration, verifying the signature and checking the source IP against the allowlist.");
|
||||
|
||||
group.MapPut("/webhooks/{tenantId}/{channelId}/allowlist", UpdateWebhookAllowlistAsync)
|
||||
.WithName("UpdateWebhookAllowlist")
|
||||
.WithDescription("Updates IP allowlist for a webhook.");
|
||||
.WithDescription("Replaces the IP allowlist for a webhook channel. An empty list removes all IP restrictions.");
|
||||
|
||||
// HTML sanitization endpoints
|
||||
group.MapPost("/html/sanitize", SanitizeHtmlAsync)
|
||||
.WithName("SanitizeHtml")
|
||||
.WithDescription("Sanitizes HTML content.");
|
||||
.WithDescription("Sanitizes HTML content using the specified profile (or the default profile if omitted), removing disallowed tags and attributes.");
|
||||
|
||||
group.MapPost("/html/validate", ValidateHtmlAsync)
|
||||
.WithName("ValidateHtml")
|
||||
.WithDescription("Validates HTML content.");
|
||||
.WithDescription("Validates HTML content against the specified profile and returns whether it is safe, along with details of any disallowed elements found.");
|
||||
|
||||
group.MapPost("/html/strip", StripHtmlTagsAsync)
|
||||
.WithName("StripHtmlTags")
|
||||
.WithDescription("Strips all HTML tags from content.");
|
||||
.WithDescription("Removes all HTML tags from the input, returning plain text. Useful for generating fallback plain-text notification bodies from HTML templates.");
|
||||
|
||||
// Tenant isolation endpoints
|
||||
group.MapPost("/tenants/validate", ValidateTenantAccessAsync)
|
||||
.WithName("ValidateTenantAccess")
|
||||
.WithDescription("Validates tenant access to a resource.");
|
||||
.WithDescription("Validates whether the calling tenant is permitted to access the specified resource type and ID for the requested operation. Returns a violation record if access is denied.");
|
||||
|
||||
group.MapGet("/tenants/{tenantId}/violations", GetTenantViolationsAsync)
|
||||
.WithName("GetTenantViolations")
|
||||
.WithDescription("Gets tenant isolation violations.");
|
||||
.WithDescription("Returns recorded tenant isolation violations for the specified tenant, optionally filtered by time range.");
|
||||
|
||||
group.MapPost("/tenants/fuzz-test", RunTenantFuzzTestAsync)
|
||||
.WithName("RunTenantFuzzTest")
|
||||
.WithDescription("Runs tenant isolation fuzz tests.");
|
||||
.WithDescription("Runs automated tenant isolation fuzz tests, exercising cross-tenant access paths to surface potential data-leakage vulnerabilities.");
|
||||
|
||||
group.MapPost("/tenants/grants", GrantCrossTenantAccessAsync)
|
||||
.WithName("GrantCrossTenantAccess")
|
||||
.WithDescription("Grants cross-tenant access to a resource.");
|
||||
.WithDescription("Grants a target tenant time-bounded access to a resource owned by the owner tenant. Grant records are auditable and expire automatically.");
|
||||
|
||||
group.MapDelete("/tenants/grants", RevokeCrossTenantAccessAsync)
|
||||
.WithName("RevokeCrossTenantAccess")
|
||||
.WithDescription("Revokes cross-tenant access.");
|
||||
.WithDescription("Revokes a previously granted cross-tenant access grant before its expiry. Revocation is immediate and recorded in the audit log.");
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notifier.WebService.Constants;
|
||||
using StellaOps.Notifier.WebService.Extensions;
|
||||
using StellaOps.Notifier.Worker.Simulation;
|
||||
using StellaOps.Notify.Models;
|
||||
@@ -21,7 +23,9 @@ public static class SimulationEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/simulate")
|
||||
.WithTags("Simulation")
|
||||
.WithOpenApi();
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapPost("/", SimulateAsync)
|
||||
.WithName("SimulateRules")
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notifier.WebService.Constants;
|
||||
using StellaOps.Notifier.WebService.Extensions;
|
||||
using StellaOps.Notifier.Worker.StormBreaker;
|
||||
|
||||
@@ -19,7 +21,9 @@ public static class StormBreakerEndpoints
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v2/storm-breaker")
|
||||
.WithTags("Storm Breaker")
|
||||
.WithOpenApi();
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
// List active storms for tenant
|
||||
group.MapGet("/storms", async (
|
||||
@@ -48,7 +52,8 @@ public static class StormBreakerEndpoints
|
||||
});
|
||||
})
|
||||
.WithName("ListActiveStorms")
|
||||
.WithSummary("Lists all active notification storms for a tenant");
|
||||
.WithSummary("Lists all active notification storms for a tenant")
|
||||
.WithDescription("Returns all currently active notification storms for the tenant. A storm is declared when the same event kind fires at a rate exceeding the configured threshold, triggering suppression.");
|
||||
|
||||
// Get specific storm state
|
||||
group.MapGet("/storms/{stormKey}", async (
|
||||
@@ -79,7 +84,8 @@ public static class StormBreakerEndpoints
|
||||
});
|
||||
})
|
||||
.WithName("GetStormState")
|
||||
.WithSummary("Gets the current state of a specific storm");
|
||||
.WithSummary("Gets the current state of a specific storm")
|
||||
.WithDescription("Returns the current state of a storm identified by its storm key, including event count, suppressed count, and the time of the last summarization.");
|
||||
|
||||
// Generate storm summary
|
||||
group.MapPost("/storms/{stormKey}/summary", async (
|
||||
@@ -99,7 +105,9 @@ public static class StormBreakerEndpoints
|
||||
return Results.Ok(summary);
|
||||
})
|
||||
.WithName("GenerateStormSummary")
|
||||
.WithSummary("Generates a summary for an active storm");
|
||||
.WithSummary("Generates a summary for an active storm")
|
||||
.WithDescription("Generates and returns a suppression summary notification for the storm, delivering a single digest notification in place of all suppressed individual events.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
// Clear storm state
|
||||
group.MapDelete("/storms/{stormKey}", async (
|
||||
@@ -115,7 +123,9 @@ public static class StormBreakerEndpoints
|
||||
return Results.Ok(new { message = $"Storm '{stormKey}' cleared successfully" });
|
||||
})
|
||||
.WithName("ClearStorm")
|
||||
.WithSummary("Clears a storm state manually");
|
||||
.WithSummary("Clears a storm state manually")
|
||||
.WithDescription("Manually clears the storm state for the specified key. Subsequent events of the same kind will be processed normally until a new storm threshold is exceeded.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notifier.WebService.Constants;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
using StellaOps.Notifier.Worker.Dispatch;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
@@ -20,31 +22,43 @@ public static class TemplateEndpoints
|
||||
public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/templates")
|
||||
.WithTags("Templates");
|
||||
.WithTags("Templates")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/", ListTemplatesAsync)
|
||||
.WithName("ListTemplates")
|
||||
.WithSummary("Lists all templates for a tenant");
|
||||
.WithSummary("Lists all templates for a tenant")
|
||||
.WithDescription("Returns all notification templates for the tenant with optional filtering by key prefix, channel type, and locale. Templates define rendered message bodies used by alert routing rules.");
|
||||
|
||||
group.MapGet("/{templateId}", GetTemplateAsync)
|
||||
.WithName("GetTemplate")
|
||||
.WithSummary("Gets a template by ID");
|
||||
.WithSummary("Gets a template by ID")
|
||||
.WithDescription("Returns a single notification template by its identifier, including body, channel type, locale, render mode, format, and audit metadata.");
|
||||
|
||||
group.MapPost("/", CreateTemplateAsync)
|
||||
.WithName("CreateTemplate")
|
||||
.WithSummary("Creates a new template");
|
||||
.WithSummary("Creates a new template")
|
||||
.WithDescription("Creates a new notification template. Template body syntax is validated before persisting. Returns conflict if a template with the same ID already exists.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPut("/{templateId}", UpdateTemplateAsync)
|
||||
.WithName("UpdateTemplate")
|
||||
.WithSummary("Updates an existing template");
|
||||
.WithSummary("Updates an existing template")
|
||||
.WithDescription("Updates an existing notification template. Template body syntax is validated before persisting. An audit entry is written on update.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapDelete("/{templateId}", DeleteTemplateAsync)
|
||||
.WithName("DeleteTemplate")
|
||||
.WithSummary("Deletes a template");
|
||||
.WithSummary("Deletes a template")
|
||||
.WithDescription("Permanently removes a notification template. Rules referencing this template will fall back to channel defaults on the next delivery. An audit entry is written on deletion.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPost("/preview", PreviewTemplateAsync)
|
||||
.WithName("PreviewTemplate")
|
||||
.WithSummary("Previews a template rendering");
|
||||
.WithSummary("Previews a template rendering")
|
||||
.WithDescription("Renders a template against a sample event payload without sending any notification. Accepts either an existing templateId or an inline templateBody. Returns the rendered body, subject, and any template warnings.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notifier.WebService.Constants;
|
||||
using StellaOps.Notifier.WebService.Extensions;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
@@ -17,27 +19,31 @@ public static class ThrottleEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/throttles")
|
||||
.WithTags("Throttles")
|
||||
.WithOpenApi();
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/config", GetConfigurationAsync)
|
||||
.WithName("GetThrottleConfiguration")
|
||||
.WithSummary("Get throttle configuration")
|
||||
.WithDescription("Returns the throttle configuration for the tenant.");
|
||||
.WithDescription("Returns the throttle configuration for the tenant, including the default suppression window, per-event-kind overrides, and burst window settings. Returns platform defaults if no custom configuration exists.");
|
||||
|
||||
group.MapPut("/config", UpdateConfigurationAsync)
|
||||
.WithName("UpdateThrottleConfiguration")
|
||||
.WithSummary("Update throttle configuration")
|
||||
.WithDescription("Creates or updates the throttle configuration for the tenant.");
|
||||
.WithDescription("Creates or replaces the throttle configuration for the tenant. The default duration and optional per-event-kind overrides control how long duplicate notifications are suppressed.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapDelete("/config", DeleteConfigurationAsync)
|
||||
.WithName("DeleteThrottleConfiguration")
|
||||
.WithSummary("Delete throttle configuration")
|
||||
.WithDescription("Deletes the throttle configuration for the tenant, reverting to defaults.");
|
||||
.WithDescription("Removes the tenant-specific throttle configuration, reverting all throttle windows to the platform defaults.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPost("/evaluate", EvaluateAsync)
|
||||
.WithName("EvaluateThrottle")
|
||||
.WithSummary("Evaluate throttle duration")
|
||||
.WithDescription("Returns the effective throttle duration for an event kind.");
|
||||
.WithDescription("Returns the effective throttle duration in seconds for a given event kind, applying the tenant-specific override if present or the default if not.");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ using StellaOps.Notify.Queue;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Notifier.WebService.Constants;
|
||||
using StellaOps.Router.AspNet;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -123,6 +126,15 @@ builder.Services.AddNotifierSecurityServices(builder.Configuration);
|
||||
// Tenancy services (context accessor, RLS enforcement, channel resolution, notification enrichment)
|
||||
builder.Services.AddNotifierTenancy(builder.Configuration);
|
||||
|
||||
// Authorization policies for Notifier scopes (RASD-03)
|
||||
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();
|
||||
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
@@ -134,6 +146,7 @@ var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
|
||||
builder.Services.AddStellaOpsTenantServices();
|
||||
builder.TryAddStellaOpsLocalBinding("notifier");
|
||||
var app = builder.Build();
|
||||
app.LogStellaOpsLocalHostname("notifier");
|
||||
@@ -164,6 +177,7 @@ app.Use(async (context, next) =>
|
||||
|
||||
// Tenant context middleware (extracts and validates tenant from headers/query)
|
||||
app.UseTenantContext();
|
||||
app.UseStellaOpsTenantMiddleware();
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
app.MapPost("/api/v1/notify/pack-approvals", async (
|
||||
|
||||
Reference in New Issue
Block a user