Files
AdPlatform-Server/Management/Security/ActivityLoggingMiddleware.cs
2026-03-14 13:50:09 -07:00

120 lines
4.1 KiB
C#

using Management.Data;
using Management.Security;
using System.Text.Json;
namespace Management.Security;
/// <summary>
/// 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.
/// </summary>
public sealed class ActivityLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ActivityLoggingMiddleware> _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<ActivityLoggingMiddleware> 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<string?> 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<string>();
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;
}
}
}