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

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