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:
@@ -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) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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<DeprecationHeaderEndpointFilter>()</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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user