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:
@@ -12,6 +12,7 @@ using Microsoft.IdentityModel.Tokens;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Persistence.Extensions;
|
||||
@@ -111,6 +112,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.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
builder.TryAddStellaOpsLocalBinding("notify");
|
||||
@@ -352,6 +354,7 @@ static void ConfigureRequestPipeline(WebApplication app, NotifyWebServiceOptions
|
||||
app.UseAuthentication();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthorization();
|
||||
app.UseStellaOpsTenantMiddleware();
|
||||
|
||||
// Stella Router integration
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
@@ -359,7 +362,10 @@ static void ConfigureRequestPipeline(WebApplication app, NotifyWebServiceOptions
|
||||
|
||||
static void ConfigureEndpoints(WebApplication app)
|
||||
{
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }))
|
||||
.WithName("NotifyHealthz")
|
||||
.WithDescription("Liveness probe endpoint for the Notify service. Returns HTTP 200 with a JSON status body when the process is running. No authentication required.")
|
||||
.AllowAnonymous();
|
||||
|
||||
app.MapGet("/readyz", (ServiceStatus status) =>
|
||||
{
|
||||
@@ -384,7 +390,10 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
latencyMs = snapshot.Ready.Latency?.TotalMilliseconds
|
||||
},
|
||||
StatusCodes.Status503ServiceUnavailable);
|
||||
});
|
||||
})
|
||||
.WithName("NotifyReadyz")
|
||||
.WithDescription("Readiness probe endpoint for the Notify service. Returns HTTP 200 with a structured status body when the service is ready to accept traffic. Returns HTTP 503 if the service is not yet ready. No authentication required.")
|
||||
.AllowAnonymous();
|
||||
|
||||
var options = app.Services.GetRequiredService<IOptions<NotifyWebServiceOptions>>().Value;
|
||||
var tenantHeader = options.Api.TenantHeader;
|
||||
@@ -394,14 +403,20 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
internalGroup.MapPost("/rules/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeRule))
|
||||
.WithName("notify.rules.normalize")
|
||||
.WithDescription("Internal endpoint that upgrades a notify rule JSON payload from an older schema version to the current canonical format. Returns the normalized rule JSON.")
|
||||
.RequireAuthorization(NotifyPolicies.Operator)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
internalGroup.MapPost("/channels/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeChannel))
|
||||
.WithName("notify.channels.normalize");
|
||||
.WithName("notify.channels.normalize")
|
||||
.WithDescription("Internal endpoint that upgrades a notify channel JSON payload from an older schema version to the current canonical format. Returns the normalized channel JSON.")
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
internalGroup.MapPost("/templates/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeTemplate))
|
||||
.WithName("notify.templates.normalize");
|
||||
.WithName("notify.templates.normalize")
|
||||
.WithDescription("Internal endpoint that upgrades a notify template JSON payload from an older schema version to the current canonical format. Returns the normalized template JSON.")
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapGet("/rules", async (IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
@@ -413,6 +428,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
var rules = await repository.ListAsync(tenant, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return JsonResponse(rules.Select(ToNotifyRule));
|
||||
})
|
||||
.WithName("NotifyListRules")
|
||||
.WithDescription("Lists all notification rules for the tenant. Returns an array of rule objects including match filters and channel actions. Requires notify.viewer scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Viewer);
|
||||
|
||||
apiGroup.MapGet("/rules/{ruleId}", async (string ruleId, IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -430,6 +447,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
var rule = await repository.GetByIdAsync(tenant, id, cancellationToken).ConfigureAwait(false);
|
||||
return rule is null ? Results.NotFound() : JsonResponse(ToNotifyRule(rule));
|
||||
})
|
||||
.WithName("NotifyGetRule")
|
||||
.WithDescription("Returns the full notification rule for a specific rule ID. Returns 404 if the rule is not found. Requires notify.viewer scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Viewer);
|
||||
|
||||
apiGroup.MapPost("/rules", async (JsonNode? body, NotifySchemaMigrationService service, IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -477,6 +496,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
return CreatedJson(BuildResourceLocation(apiBasePath, "rules", ruleModel.RuleId), ruleModel);
|
||||
})
|
||||
.WithName("NotifyUpsertRule")
|
||||
.WithDescription("Creates or updates a notification rule for the tenant. Accepts the canonical rule JSON, validates schema migration, and upserts into storage. Returns 201 Created with the rule record. Requires notify.operator scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapDelete("/rules/{ruleId}", async (string ruleId, IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -494,6 +515,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
var deleted = await repository.DeleteAsync(tenant, ruleGuid, cancellationToken).ConfigureAwait(false);
|
||||
return deleted ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.WithName("NotifyDeleteRule")
|
||||
.WithDescription("Permanently removes a notification rule from the tenant. Returns 204 No Content on success or 404 if the rule is not found. Requires notify.operator scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapGet("/channels", async (IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -506,6 +529,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
var channels = await repository.GetAllAsync(tenant, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return JsonResponse(channels.Select(ToNotifyChannel));
|
||||
})
|
||||
.WithName("NotifyListChannels")
|
||||
.WithDescription("Lists all notification channels configured for the tenant, including channel type, enabled state, and configuration. Requires notify.viewer scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Viewer);
|
||||
|
||||
apiGroup.MapGet("/channels/{channelId}", async (string channelId, IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -523,6 +548,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
var channel = await repository.GetByIdAsync(tenant, id, cancellationToken).ConfigureAwait(false);
|
||||
return channel is null ? Results.NotFound() : JsonResponse(ToNotifyChannel(channel));
|
||||
})
|
||||
.WithName("NotifyGetChannel")
|
||||
.WithDescription("Returns the full channel record for a specific channel ID, including type, configuration, and enabled state. Returns 404 if the channel is not found. Requires notify.viewer scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Viewer);
|
||||
|
||||
apiGroup.MapPost("/channels", async (JsonNode? body, NotifySchemaMigrationService service, IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -570,6 +597,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
return CreatedJson(BuildResourceLocation(apiBasePath, "channels", channelModel.ChannelId), channelModel);
|
||||
})
|
||||
.WithName("NotifyUpsertChannel")
|
||||
.WithDescription("Creates or updates a notification channel for the tenant. Accepts a channel JSON payload with type and configuration, upgrades schema if needed, and upserts into storage. Returns 201 Created with the channel record. Requires notify.operator scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapPost("/channels/{channelId}/test", async (
|
||||
@@ -618,6 +647,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
})
|
||||
.WithName("NotifyTestChannel")
|
||||
.WithDescription("Sends a test notification through the specified channel to validate connectivity and configuration. Returns 202 Accepted with the test send response. Subject to test-send rate limiting. Requires notify.operator scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Operator)
|
||||
.RequireRateLimiting(NotifyRateLimitPolicies.TestSend);
|
||||
|
||||
@@ -636,6 +667,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
await repository.DeleteAsync(tenant, channelGuid, cancellationToken).ConfigureAwait(false);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithName("NotifyDeleteChannel")
|
||||
.WithDescription("Removes a notification channel from the tenant. Returns 204 No Content on successful deletion. Requires notify.operator scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapGet("/templates", async (ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -648,6 +681,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
var templates = await repository.ListAsync(tenant, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return JsonResponse(templates.Select(ToNotifyTemplate));
|
||||
})
|
||||
.WithName("NotifyListTemplates")
|
||||
.WithDescription("Lists all notification templates configured for the tenant, including body templates and locale settings. Requires notify.viewer scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Viewer);
|
||||
|
||||
apiGroup.MapGet("/templates/{templateId}", async (string templateId, ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -665,6 +700,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
var template = await repository.GetByIdAsync(tenant, templateGuid, cancellationToken).ConfigureAwait(false);
|
||||
return template is null ? Results.NotFound() : JsonResponse(ToNotifyTemplate(template));
|
||||
})
|
||||
.WithName("NotifyGetTemplate")
|
||||
.WithDescription("Returns the full notification template for a specific template ID, including channel type, body template, and locale. Returns 404 if the template is not found. Requires notify.viewer scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Viewer);
|
||||
|
||||
apiGroup.MapPost("/templates", async (JsonNode? body, NotifySchemaMigrationService service, ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -703,6 +740,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
return CreatedJson(BuildResourceLocation(apiBasePath, "templates", templateModel.TemplateId), templateModel);
|
||||
})
|
||||
.WithName("NotifyUpsertTemplate")
|
||||
.WithDescription("Creates or updates a notification template for the tenant. Accepts a template JSON payload, applies schema migration, and upserts into storage. Returns 201 Created with the template record. Requires notify.operator scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapDelete("/templates/{templateId}", async (string templateId, ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -720,6 +759,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
await repository.DeleteAsync(tenant, templateGuid, cancellationToken).ConfigureAwait(false);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithName("NotifyDeleteTemplate")
|
||||
.WithDescription("Removes a notification template from the tenant. Returns 204 No Content on successful deletion. Requires notify.operator scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapPost("/deliveries", async ([FromBody] JsonNode? body, IDeliveryRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -771,6 +812,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
BuildResourceLocation(apiBasePath, "deliveries", delivery.DeliveryId),
|
||||
ToDeliveryDetail(saved, channelName: null, channelType: null));
|
||||
})
|
||||
.WithName("NotifyCreateDelivery")
|
||||
.WithDescription("Records a notification delivery attempt for the tenant. Accepts the canonical delivery JSON including rendered content, channel reference, and delivery status. Returns 201 Created with the delivery detail record. Requires notify.operator scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapGet("/deliveries", async (
|
||||
@@ -849,6 +892,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
continuationToken = nextCursor
|
||||
});
|
||||
})
|
||||
.WithName("NotifyListDeliveries")
|
||||
.WithDescription("Queries delivery history for the tenant with optional filters for status, channel, event type, and time range. Supports pagination via limit and offset. Returns a paged list of delivery summary records. Subject to delivery-history rate limiting. Requires notify.viewer scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Viewer)
|
||||
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
|
||||
|
||||
@@ -895,6 +940,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
return JsonResponse(ToDeliveryDetail(delivery, channelName, channelType));
|
||||
})
|
||||
.WithName("NotifyGetDelivery")
|
||||
.WithDescription("Returns the full delivery detail record for a specific delivery ID, including channel name, rendered subject, attempt count, sent timestamp, and error information. Subject to delivery-history rate limiting. Requires notify.viewer scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Viewer)
|
||||
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
|
||||
|
||||
@@ -950,6 +997,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
BuildResourceLocation(apiBasePath, "digests", request.DigestKey),
|
||||
ToDigestResponse(saved));
|
||||
})
|
||||
.WithName("NotifyUpsertDigest")
|
||||
.WithDescription("Creates or updates a notification digest accumulator for a channel and recipient. Digests collect events over a collection window before sending a batched notification. Returns 201 Created with the digest record. Requires notify.operator scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapGet("/digests/{actionKey}", async (
|
||||
@@ -978,6 +1027,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
var digest = await repository.GetByKeyAsync(tenant, channelGuid, recipient, actionKey, cancellationToken).ConfigureAwait(false);
|
||||
return digest is null ? Results.NotFound() : JsonResponse(ToDigestResponse(digest));
|
||||
})
|
||||
.WithName("NotifyGetDigest")
|
||||
.WithDescription("Returns the current state of a notification digest identified by channel, recipient, and action key. Returns 404 if no active digest is found. Requires notify.viewer scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Viewer);
|
||||
|
||||
apiGroup.MapDelete("/digests/{actionKey}", async (
|
||||
@@ -1006,6 +1057,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
var deleted = await repository.DeleteByKeyAsync(tenant, channelGuid, recipient, actionKey, cancellationToken).ConfigureAwait(false);
|
||||
return deleted ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.WithName("NotifyDeleteDigest")
|
||||
.WithDescription("Removes a pending notification digest for a channel and recipient, cancelling any queued batched notification. Returns 204 No Content on success or 404 if not found. Requires notify.operator scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapPost("/audit", async ([FromBody] JsonNode? body, INotifyAuditRepository repository, TimeProvider timeProvider, HttpContext context, ClaimsPrincipal user, CancellationToken cancellationToken) =>
|
||||
@@ -1041,6 +1094,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
var id = await repository.CreateAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
return CreatedJson(BuildResourceLocation(apiBasePath, "audit", id.ToString()), new { id });
|
||||
})
|
||||
.WithName("NotifyCreateAuditEntry")
|
||||
.WithDescription("Records an audit log entry for a notify action performed by the authenticated user. Captures the action, entity type, entity ID, and optional payload. Returns 201 Created with the new audit entry ID. Requires notify.operator scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapGet("/audit", async (INotifyAuditRepository repository, HttpContext context, [FromQuery] int? limit, [FromQuery] int? offset, CancellationToken cancellationToken) =>
|
||||
@@ -1066,6 +1121,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
return JsonResponse(payload);
|
||||
})
|
||||
.WithName("NotifyListAuditEntries")
|
||||
.WithDescription("Returns paginated audit log entries for the tenant, ordered by creation time descending. Supports limit and offset parameters for pagination. Requires notify.viewer scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Viewer);
|
||||
|
||||
apiGroup.MapPost("/locks/acquire", async ([FromBody] AcquireLockRequest request, ILockRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -1078,6 +1135,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
var acquired = await repository.TryAcquireAsync(tenant, request.Resource, request.Owner, TimeSpan.FromSeconds(request.TtlSeconds), cancellationToken).ConfigureAwait(false);
|
||||
return JsonResponse(new { acquired });
|
||||
})
|
||||
.WithName("NotifyAcquireLock")
|
||||
.WithDescription("Attempts to acquire a distributed advisory lock for a named resource and owner with a TTL. Returns a JSON object with an acquired boolean indicating whether the lock was successfully taken. Used for coordinating plugin dispatch and digest flushing. Requires notify.operator scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapPost("/locks/release", async ([FromBody] ReleaseLockRequest request, ILockRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -1090,6 +1149,8 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
var released = await repository.ReleaseAsync(tenant, request.Resource, request.Owner, cancellationToken).ConfigureAwait(false);
|
||||
return released ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.WithName("NotifyReleaseLock")
|
||||
.WithDescription("Releases a previously acquired distributed advisory lock for the specified resource and owner. Returns 204 No Content on success or 404 if the lock was not found or already released. Requires notify.operator scope.")
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
}
|
||||
|
||||
@@ -1402,16 +1463,25 @@ static T? TryDeserialize<T>(string? json)
|
||||
|
||||
static bool TryResolveTenant(HttpContext context, string tenantHeader, out string tenant, out IResult? error)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue(tenantHeader, out var header) || string.IsNullOrWhiteSpace(header))
|
||||
// Delegate to unified StellaOps tenant resolver (claims + canonical headers + legacy headers)
|
||||
if (StellaOpsTenantResolver.TryResolveTenantId(context, out var resolvedTenant, out var resolverError))
|
||||
{
|
||||
tenant = string.Empty;
|
||||
error = Results.BadRequest(new { error = $"{tenantHeader} header is required." });
|
||||
return false;
|
||||
tenant = resolvedTenant;
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
tenant = header.ToString().Trim();
|
||||
error = null;
|
||||
return true;
|
||||
// Fall back to legacy configurable header for backward compatibility
|
||||
if (context.Request.Headers.TryGetValue(tenantHeader, out var header) && !string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
tenant = header.ToString().Trim().ToLowerInvariant();
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
tenant = string.Empty;
|
||||
error = Results.BadRequest(new { error = $"Tenant is required. Provide via stellaops:tenant claim, X-StellaOps-Tenant header, or {tenantHeader} header.", error_code = resolverError ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
|
||||
static string BuildResourceLocation(string basePath, params string[] segments)
|
||||
|
||||
Reference in New Issue
Block a user