using System.Diagnostics; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Types.Enums; namespace ScadaLink.InboundAPI.Middleware; /// /// Audit Log #23 (M4 Bundle D, T7) — emits one /// row per inbound API request via covering the /// full set of response shapes: /// /// /// 2xx / non-error → with . /// 401/403 → with . /// 4xx (non-auth) / 5xx / thrown exception → with . /// /// /// /// Best-effort contract (alog.md §13). Audit emission NEVER alters the /// user-facing HTTP response — a thrown writer or any other failure during /// emission is caught, logged at warning, and dropped. A handler exception is /// recorded on the audit row then re-thrown so the framework error path stays /// authoritative. /// /// /// /// Actor resolution. Inbound API auth runs inside the endpoint handler /// (no UseAuthentication-backed scheme populates /// for X-API-Key callers), so the handler stashes the resolved API key name on /// under after /// ApiKeyValidator.ValidateAsync succeeds. The middleware reads it in /// its finally block — on auth failures the key remains absent and /// stays null (we never echo back an /// unauthenticated principal). /// /// /// /// Body capture. The request body is buffered via /// then /// rewound so the downstream endpoint handler still sees the full payload. /// Response body capture is deferred to M5 — wrapping Response.Body /// requires a memory-stream swap that interacts awkwardly with Minimal API's /// Results.Json/Results.Text writers; the M4 deliverable emits /// the audit row with left null. /// /// public sealed class AuditWriteMiddleware { /// /// key used by the endpoint handler to publish /// the resolved API key name once ApiKeyValidator.ValidateAsync has /// succeeded. Exposed as a constant so the handler and middleware share a /// single source of truth (no stringly-typed coupling). /// public const string AuditActorItemKey = "ScadaLink.InboundAPI.AuditActor"; private readonly RequestDelegate _next; private readonly ICentralAuditWriter _auditWriter; private readonly ILogger _logger; public AuditWriteMiddleware( RequestDelegate next, ICentralAuditWriter auditWriter, ILogger logger) { _next = next ?? throw new ArgumentNullException(nameof(next)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task InvokeAsync(HttpContext ctx) { var sw = Stopwatch.StartNew(); // Buffer the request body up front so we can both audit it and let the // downstream handler still parse it. EnableBuffering swaps the request // stream for a seekable wrapper that the framework rewinds at the end // of the pipeline for us — but we also rewind to position 0 after our // own read so the very next reader starts from the top. ctx.Request.EnableBuffering(); var requestBody = await ReadBufferedRequestBodyAsync(ctx.Request).ConfigureAwait(false); Exception? thrown = null; try { await _next(ctx).ConfigureAwait(false); } catch (Exception ex) { thrown = ex; // Re-throw — audit emission is BEST EFFORT, but the user-facing // request's own error path must remain authoritative (alog.md §13). throw; } finally { sw.Stop(); EmitInboundAudit(ctx, sw.ElapsedMilliseconds, thrown, requestBody); } } /// /// Builds and writes the row for the /// request. Wrapped in try/catch so a thrown writer or any other emission /// failure stays out of the user-facing response (alog.md §13). /// private void EmitInboundAudit( HttpContext ctx, long durationMs, Exception? thrown, string? requestBody) { try { var statusCode = ctx.Response.StatusCode; var isAuthFailure = statusCode is 401 or 403; var kind = isAuthFailure ? AuditKind.InboundAuthFailure : AuditKind.InboundRequest; // A thrown handler exception is always Failed; otherwise any 4xx/5xx // response signals failure. 2xx/3xx are Delivered. var status = (thrown != null || statusCode >= 400) ? AuditStatus.Failed : AuditStatus.Delivered; var actor = isAuthFailure ? null : ResolveActor(ctx); var methodName = ResolveMethodName(ctx); var extra = JsonSerializer.Serialize(new { remoteIp = ctx.Connection.RemoteIpAddress?.ToString(), userAgent = ctx.Request.Headers.UserAgent.ToString(), }); var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiInbound, Kind = kind, // Audit Log #23: a fresh per-request correlation id so the // inbound row carries a request identifier (closes the design // gap that inbound rows should be correlatable). // // This id is intentionally request-local: it is NOT bridged to // RouteHelper's routed-call correlation id or to // HttpContext.TraceIdentifier. Threading an inbound request's // correlation id through to the routed script execution (so an // inbound call and the outbound API/DB rows it triggers share // one id) is a deliberate future follow-up, out of scope here. CorrelationId = Guid.NewGuid(), Actor = actor, Target = methodName, Status = status, HttpStatus = statusCode, DurationMs = (int)Math.Min(durationMs, int.MaxValue), ErrorMessage = thrown?.Message, RequestSummary = requestBody, // Response body capture is deferred to M5 (see XML doc above). ResponseSummary = null, PayloadTruncated = false, Extra = extra, // Central direct-write — no site-local forwarding state. ForwardState = null, }; // Fire-and-forget — the writer itself swallows; the additional // try/catch around the fire still protects us if WriteAsync throws // synchronously before returning a task. _ = _auditWriter.WriteAsync(evt); } catch (Exception ex) { _logger.LogWarning( ex, "AuditWriteMiddleware emission failed for {Method} {Path} (status {Status})", ctx.Request.Method, ctx.Request.Path, ctx.Response.StatusCode); } } /// /// Reads the buffered request body fully into a string and rewinds the /// stream so the downstream handler sees the unconsumed payload. Returns /// null for empty/missing bodies so the audit row's /// stays null rather than /// containing an empty string. /// private static async Task ReadBufferedRequestBodyAsync(HttpRequest request) { if (request.ContentLength is 0) { return null; } try { request.Body.Position = 0; using var reader = new StreamReader( request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true); var content = await reader.ReadToEndAsync().ConfigureAwait(false); request.Body.Position = 0; return string.IsNullOrEmpty(content) ? null : content; } catch { // A failed body read must not abort the request — fall through // with a null RequestSummary; the audit row still records the // outcome. return null; } } /// /// Reads the API key name the endpoint handler stashed on /// after successful auth. Falls back to /// the authenticated user name when an ASP.NET scheme has populated /// (defensive — currently unused for inbound /// API but cheap and forward-compatible). /// private static string? ResolveActor(HttpContext ctx) { if (ctx.Items.TryGetValue(AuditActorItemKey, out var stashed) && stashed is string name && !string.IsNullOrWhiteSpace(name)) { return name; } var user = ctx.User; if (user?.Identity is { IsAuthenticated: true, Name: { Length: > 0 } userName }) { return userName; } return null; } /// /// Pulls the {methodName} route value off the request. Falls back to /// the last segment of when no route value /// is bound (e.g. when the request never reached the matched endpoint). /// private static string? ResolveMethodName(HttpContext ctx) { if (ctx.Request.RouteValues.TryGetValue("methodName", out var raw) && raw is string method && !string.IsNullOrWhiteSpace(method)) { return method; } var path = ctx.Request.Path.Value; if (string.IsNullOrEmpty(path)) { return null; } var lastSlash = path.LastIndexOf('/'); if (lastSlash < 0 || lastSlash == path.Length - 1) { return null; } return path[(lastSlash + 1)..]; } }