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:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")

View File

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

View File

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

View File

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

View File

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