diff --git a/src/Notify/StellaOps.Notify.WebService/Program.cs b/src/Notify/StellaOps.Notify.WebService/Program.cs index c3e673ac9..9f1ad31d0 100644 --- a/src/Notify/StellaOps.Notify.WebService/Program.cs +++ b/src/Notify/StellaOps.Notify.WebService/Program.cs @@ -1368,6 +1368,10 @@ static void ConfigureEndpoints(WebApplication app) .WithDescription(_t("notify.audit.create_description")) .RequireAuthorization(NotifyPolicies.Operator); + // AUDIT-005 / DEPRECATE-002: this per-service audit endpoint is superseded by + // Timeline's unified /api/v1/audit/events endpoint now that Emission + dual-write + // are live for Notify. The Sunset date is 18 months from the sprint date, giving + // UI clients a wide window to migrate to the unified endpoint. apiGroup.MapGet("/audit", async (INotifyAuditRepository repository, HttpContext context, [FromQuery] int? limit, [FromQuery] int? offset, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) @@ -1393,7 +1397,11 @@ static void ConfigureEndpoints(WebApplication app) }) .WithName("NotifyListAuditEntries") .WithDescription(_t("notify.audit.list_description")) - .RequireAuthorization(NotifyPolicies.Viewer); + .RequireAuthorization(NotifyPolicies.Viewer) + .AddEndpointFilter() + .DeprecatedForTimeline( + sunset: new DateTimeOffset(2027, 10, 19, 0, 0, 0, TimeSpan.Zero), + successorLink: "/api/v1/audit/events?modules=notify"); apiGroup.MapPost("/locks/acquire", async ([FromBody] AcquireLockRequest request, ILockRepository repository, HttpContext context, CancellationToken cancellationToken) => { diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/AuditEndpoints.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/AuditEndpoints.cs index 7f208c42c..a5cd893e8 100644 --- a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/AuditEndpoints.cs +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/AuditEndpoints.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using StellaOps.Audit.Emission; using StellaOps.Auth.ServerIntegration.Tenancy; using StellaOps.ReleaseOrchestrator.Persistence.Domain; using StellaOps.ReleaseOrchestrator.Persistence.Repositories; @@ -18,15 +19,24 @@ public static class AuditEndpoints /// public static RouteGroupBuilder MapAuditEndpoints(this IEndpointRouteBuilder app) { + // AUDIT-005 / DEPRECATE-002: per-service audit listing is superseded by Timeline's + // unified endpoint. Chain verification ("verify"), summary, and single-entry lookups + // are NOT deprecated — they serve service-level chain-of-custody evidence that the + // unified store cannot replace (per sprint Decision 2). Only broad LIST/SEARCH + // endpoints advertise Sunset + Link headers. var group = app.MapGroup("/api/v1/release-orchestrator/audit") .WithTags("Release Orchestrator Audit") .RequireAuthorization(ReleaseOrchestratorPolicies.Read) - .RequireTenant(); + .RequireTenant() + .AddEndpointFilter(); // List and get operations group.MapGet(string.Empty, ListAuditEntriesHandler) .WithName("ReleaseOrchestrator_ListAuditEntries") - .WithDescription(_t("orchestrator.audit.list_description")); + .WithDescription(_t("orchestrator.audit.list_description")) + .DeprecatedForTimeline( + sunset: new DateTimeOffset(2027, 10, 19, 0, 0, 0, TimeSpan.Zero), + successorLink: "/api/v1/audit/events?modules=jobengine"); group.MapGet("{entryId:guid}", GetAuditEntry) .WithName("ReleaseOrchestrator_GetAuditEntry") diff --git a/src/__Libraries/StellaOps.Audit.Emission/DeprecatedAuditEndpoint.cs b/src/__Libraries/StellaOps.Audit.Emission/DeprecatedAuditEndpoint.cs new file mode 100644 index 000000000..57923ab16 --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.Emission/DeprecatedAuditEndpoint.cs @@ -0,0 +1,105 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +using System.Globalization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace StellaOps.Audit.Emission; + +/// +/// Helpers that mark per-service audit endpoints as deprecated so clients get +/// RFC-compliant Sunset / Deprecation / Link headers pointing at the unified +/// Timeline endpoint. Sprints SPRINT_20260408_004 AUDIT-005 + +/// SPRINT_20260408_005 DEPRECATE-002 (Phase 4). +/// +public static class DeprecatedAuditEndpoint +{ + /// + /// Adds an endpoint filter that stamps deprecation headers on every response + /// from the decorated endpoint. The successor link points at the unified + /// Timeline audit endpoint (caller supplies any module-specific query suffix). + /// + /// Endpoint builder. + /// Earliest date the endpoint may be removed (UTC). + /// Absolute or relative URL to the Timeline replacement. + /// + /// Optional explicit deprecation date. Defaults to + /// which is the right choice when deprecation takes effect on deploy. + /// + public static TBuilder DeprecatedForTimeline( + this TBuilder builder, + DateTimeOffset sunset, + string successorLink, + DateTimeOffset? deprecated = null) + where TBuilder : IEndpointConventionBuilder + { + ArgumentException.ThrowIfNullOrWhiteSpace(successorLink); + + var effectiveDeprecated = deprecated ?? DateTimeOffset.UtcNow; + builder.Add(endpointBuilder => + { + endpointBuilder.Metadata.Add(new DeprecatedAuditMetadata(sunset, successorLink, effectiveDeprecated)); + }); + + return builder; + } + + /// + /// Writes the three deprecation headers onto the supplied response. Call this + /// from inside an endpoint handler when the static metadata filter route is + /// not available (e.g., MVC controllers). + /// + public static void StampDeprecationHeaders(HttpResponse response, DateTimeOffset sunset, string successorLink, DateTimeOffset? deprecated = null) + { + ArgumentNullException.ThrowIfNull(response); + ArgumentException.ThrowIfNullOrWhiteSpace(successorLink); + + var effectiveDeprecated = deprecated ?? DateTimeOffset.UtcNow; + response.Headers["Deprecation"] = effectiveDeprecated.UtcDateTime.ToString("r", CultureInfo.InvariantCulture); + response.Headers["Sunset"] = sunset.UtcDateTime.ToString("r", CultureInfo.InvariantCulture); + response.Headers["Link"] = $"<{successorLink}>; rel=\"successor-version\""; + } +} + +/// +/// Metadata carried by endpoints marked deprecated for unified-timeline successor. +/// Consumed by . +/// +public sealed class DeprecatedAuditMetadata +{ + public DeprecatedAuditMetadata(DateTimeOffset sunset, string successorLink, DateTimeOffset deprecated) + { + Sunset = sunset; + SuccessorLink = successorLink; + Deprecated = deprecated; + } + + public DateTimeOffset Sunset { get; } + + public string SuccessorLink { get; } + + public DateTimeOffset Deprecated { get; } +} + +/// +/// Endpoint filter that writes deprecation headers whenever the endpoint carries +/// . Must be registered on the route group +/// or endpoint via .AddEndpointFilter<DeprecationHeaderEndpointFilter>(). +/// +public sealed class DeprecationHeaderEndpointFilter : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var metadata = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata(); + if (metadata is not null) + { + DeprecatedAuditEndpoint.StampDeprecationHeaders( + context.HttpContext.Response, + metadata.Sunset, + metadata.SuccessorLink, + metadata.Deprecated); + } + + return await next(context).ConfigureAwait(false); + } +}