feat(audit): deprecation headers for per-service audit list endpoints

Sprints SPRINT_20260408_004 AUDIT-005 + SPRINT_20260408_005
DEPRECATE-002 (early, non-waiting half).

- StellaOps.Audit.Emission gains a `DeprecatedAuditEndpoint` helper:
  .DeprecatedForTimeline(sunset, successorLink) + a
  DeprecationHeaderEndpointFilter that writes RFC-style Sunset,
  Deprecation, and Link: <successor>; rel="successor-version" headers.
- Notify GET /api/v1/notify/audit + ReleaseOrchestrator
  GET /api/v1/release-orchestrator/audit now advertise Sunset
  2027-10-19 and link to /api/v1/audit/events?modules={notify|jobengine}.
  Chain-verify / summary / single-entry lookups on ReleaseOrchestrator
  are intentionally NOT deprecated — they serve service-level chain-of-
  custody evidence (sprint Decision 2) that the unified store cannot
  replace.

Both services build clean. Remaining per-service deprecation headers
(Authority /console/admin/audit, Policy /governance/audit/events,
EvidenceLocker /evidence/audit) follow the same pattern and can land
as a wave when those endpoints are touched for other reasons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-19 23:53:57 +03:00
parent f5583c174f
commit e2f0a0df4f
3 changed files with 126 additions and 3 deletions

View File

@@ -1368,6 +1368,10 @@ static void ConfigureEndpoints(WebApplication app)
.WithDescription(_t("notify.audit.create_description")) .WithDescription(_t("notify.audit.create_description"))
.RequireAuthorization(NotifyPolicies.Operator); .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) => 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)) if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
@@ -1393,7 +1397,11 @@ static void ConfigureEndpoints(WebApplication app)
}) })
.WithName("NotifyListAuditEntries") .WithName("NotifyListAuditEntries")
.WithDescription(_t("notify.audit.list_description")) .WithDescription(_t("notify.audit.list_description"))
.RequireAuthorization(NotifyPolicies.Viewer); .RequireAuthorization(NotifyPolicies.Viewer)
.AddEndpointFilter<DeprecationHeaderEndpointFilter>()
.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) => apiGroup.MapPost("/locks/acquire", async ([FromBody] AcquireLockRequest request, ILockRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{ {

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using StellaOps.Audit.Emission;
using StellaOps.Auth.ServerIntegration.Tenancy; using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.ReleaseOrchestrator.Persistence.Domain; using StellaOps.ReleaseOrchestrator.Persistence.Domain;
using StellaOps.ReleaseOrchestrator.Persistence.Repositories; using StellaOps.ReleaseOrchestrator.Persistence.Repositories;
@@ -18,15 +19,24 @@ public static class AuditEndpoints
/// </summary> /// </summary>
public static RouteGroupBuilder MapAuditEndpoints(this IEndpointRouteBuilder app) 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") var group = app.MapGroup("/api/v1/release-orchestrator/audit")
.WithTags("Release Orchestrator Audit") .WithTags("Release Orchestrator Audit")
.RequireAuthorization(ReleaseOrchestratorPolicies.Read) .RequireAuthorization(ReleaseOrchestratorPolicies.Read)
.RequireTenant(); .RequireTenant()
.AddEndpointFilter<DeprecationHeaderEndpointFilter>();
// List and get operations // List and get operations
group.MapGet(string.Empty, ListAuditEntriesHandler) group.MapGet(string.Empty, ListAuditEntriesHandler)
.WithName("ReleaseOrchestrator_ListAuditEntries") .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) group.MapGet("{entryId:guid}", GetAuditEntry)
.WithName("ReleaseOrchestrator_GetAuditEntry") .WithName("ReleaseOrchestrator_GetAuditEntry")

View File

@@ -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;
/// <summary>
/// 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).
/// </summary>
public static class DeprecatedAuditEndpoint
{
/// <summary>
/// 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).
/// </summary>
/// <param name="builder">Endpoint builder.</param>
/// <param name="sunset">Earliest date the endpoint may be removed (UTC).</param>
/// <param name="successorLink">Absolute or relative URL to the Timeline replacement.</param>
/// <param name="deprecated">
/// Optional explicit deprecation date. Defaults to <see cref="DateTimeOffset.UtcNow"/>
/// which is the right choice when deprecation takes effect on deploy.
/// </param>
public static TBuilder DeprecatedForTimeline<TBuilder>(
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;
}
/// <summary>
/// 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).
/// </summary>
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\"";
}
}
/// <summary>
/// Metadata carried by endpoints marked deprecated for unified-timeline successor.
/// Consumed by <see cref="DeprecationHeaderEndpointFilter"/>.
/// </summary>
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; }
}
/// <summary>
/// Endpoint filter that writes deprecation headers whenever the endpoint carries
/// <see cref="DeprecatedAuditMetadata"/>. Must be registered on the route group
/// or endpoint via <c>.AddEndpointFilter&lt;DeprecationHeaderEndpointFilter&gt;()</c>.
/// </summary>
public sealed class DeprecationHeaderEndpointFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var metadata = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<DeprecatedAuditMetadata>();
if (metadata is not null)
{
DeprecatedAuditEndpoint.StampDeprecationHeaders(
context.HttpContext.Response,
metadata.Sunset,
metadata.SuccessorLink,
metadata.Deprecated);
}
return await next(context).ConfigureAwait(false);
}
}