using Management.Data; using Management.Security; using System.Text.Json; namespace Management.Security; /// /// Logs all mutating (non-GET) requests to tbAdminActivity when a staff JWT is present. /// /// Enables request body buffering so a compact filter summary can be extracted /// after the controller has run. Fire-and-forget — never delays the response. /// Registered after ClientAuthMiddleware so ClientContext is populated. /// public sealed class ActivityLoggingMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; private static readonly string[] _skipPrefixes = { "/api/help", "/api/test", "/swagger", "/health" }; // Body keys treated as filter context worth surfacing in the activity log. // Pagination keys (page, pageSize) are intentionally excluded. private static readonly string[] _filterKeys = [ "search", "status", "clientId", "category", "network", "dateFrom", "dateTo", "action", "ruleId", "initiativeId", ]; public ActivityLoggingMiddleware(RequestDelegate next, ILogger logger) { _next = next; _logger = logger; } public async Task InvokeAsync(HttpContext context, ClientContext clientContext, SqlService sql) { var method = context.Request.Method; var isRead = HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method); // Buffer request body before calling next so we can re-read it after if (!isRead) context.Request.EnableBuffering(); await _next(context); if (isRead) return; var oid = clientContext.ClientId; if (string.IsNullOrWhiteSpace(oid) || clientContext.IsDevBypass) return; var path = context.Request.Path.Value ?? ""; if (_skipPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase))) return; var statusCode = context.Response.StatusCode; var filterSummary = await ReadFilterSummaryAsync(context.Request); _ = Task.Run(async () => { try { var rqst = JsonSerializer.Serialize(new { oid, email = clientContext.Email, displayName = clientContext.ClientName, method, path, statusCode, filter = filterSummary, // null for bodies with no recognised filter keys }); await sql.ExecProcAsync("dbo.spAdminActivity", "log", rqst); } catch (Exception ex) { _logger.LogWarning("[Activity] Log failed: {Message}", ex.Message); } }); } private async Task ReadFilterSummaryAsync(HttpRequest request) { if (request.ContentLength is null or 0) return null; if (request.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) != true) return null; try { request.Body.Seek(0, SeekOrigin.Begin); using var reader = new StreamReader(request.Body, leaveOpen: true); var body = await reader.ReadToEndAsync(); if (string.IsNullOrWhiteSpace(body)) return null; using var doc = JsonDocument.Parse(body); var parts = new List(); foreach (var key in _filterKeys) { if (!doc.RootElement.TryGetProperty(key, out var val)) continue; if (val.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) continue; var str = val.ValueKind == JsonValueKind.String ? val.GetString() : val.ToString(); if (!string.IsNullOrWhiteSpace(str)) parts.Add($"{key}={str}"); } return parts.Count > 0 ? string.Join(" ", parts) : null; } catch { return null; } } }