feat(mgmt): /api/audit/{query,export} endpoints with permission gates (#23 M8)

This commit is contained in:
Joseph Doherty
2026-05-20 21:49:14 -04:00
parent 263884fa63
commit a1bdd94d4c
7 changed files with 968 additions and 4 deletions

View File

@@ -201,6 +201,10 @@ try
app.MapCentralUI<ScadaLink.Host.Components.App>();
app.MapInboundAPI();
app.MapManagementAPI();
// Audit Log #23 (M8): CLI-facing /api/audit/{query,export} routes. Same
// Basic-Auth + LDAP mechanism as /management; gated on the OperationalAudit
// / AuditExport role sets.
app.MapAuditAPI();
app.MapHub<ScadaLink.ManagementService.DebugStreamHub>("/hubs/debug-stream");
// Compile and register all Inbound API method scripts at startup

View File

@@ -0,0 +1,554 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Management;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Security;
namespace ScadaLink.ManagementService;
/// <summary>
/// Minimal-API endpoints exposing the central Audit Log (#23) over HTTP for the
/// ScadaLink CLI (M8). Two routes:
/// <list type="bullet">
/// <item><c>GET /api/audit/query</c> — keyset-paged JSON page, gated on the
/// <see cref="AuthorizationPolicies.OperationalAudit"/> permission.</item>
/// <item><c>GET /api/audit/export</c> — streamed bulk export (csv / jsonl;
/// parquet returns HTTP 501), gated on the
/// <see cref="AuthorizationPolicies.AuditExport"/> permission.</item>
/// </list>
///
/// <para>
/// <b>Auth mechanism.</b> ManagementService ships NO ASP.NET authorization-policy
/// pipeline — the existing <c>/management</c> endpoint
/// (<see cref="ManagementEndpoints"/>) authenticates each request by hand:
/// decode HTTP Basic Auth, bind against LDAP via <see cref="LdapAuthService"/>,
/// then map LDAP groups to roles via <see cref="RoleMapper"/>. These audit
/// endpoints follow that exact mechanism rather than <c>.RequireAuthorization()</c>
/// so the CLI authenticates the same way it does for every other management
/// call (HTTP Basic). Permission gating is then a role-set membership check
/// against <see cref="AuthorizationPolicies.OperationalAuditRoles"/> /
/// <see cref="AuthorizationPolicies.AuditExportRoles"/> — the very role sets the
/// CentralUI's <see cref="AuthorizationPolicies"/> enforces, so the two surfaces
/// stay in lockstep.
/// </para>
///
/// <para>
/// <b>Parquet.</b> No Parquet library is referenced by the solution and
/// <c>Component-AuditLog.md</c> explicitly defers Parquet archival to v1.x, so
/// <c>format=parquet</c> returns <c>501 Not Implemented</c> with a guidance
/// message rather than a half-built binary stream. csv + jsonl are fully
/// implemented.
/// </para>
/// </summary>
public static class AuditEndpoints
{
/// <summary>Default rows per page for <c>/api/audit/query</c>.</summary>
public const int DefaultPageSize = 100;
/// <summary>Hard ceiling on a single query page — keeps one page bounded in memory.</summary>
public const int MaxPageSize = 1000;
/// <summary>Repository round-trip size used while streaming an export.</summary>
public const int ExportPageSize = 1000;
/// <summary>
/// Serializer for individual <see cref="AuditEvent"/> rows (query results +
/// jsonl export lines). Drops null columns so a sparse audit row stays
/// compact on the wire.
/// </summary>
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>
/// Serializer for the <c>/api/audit/query</c> response envelope. Unlike
/// <see cref="JsonOptions"/> it does NOT ignore nulls — the contract
/// requires <c>nextCursor</c> to be present as an explicit <c>null</c> when
/// there is no further page, so the CLI can rely on the key always existing.
/// </summary>
private static readonly JsonSerializerOptions EnvelopeJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
public static IEndpointRouteBuilder MapAuditAPI(this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/api/audit/query", (Delegate)HandleQuery);
endpoints.MapGet("/api/audit/export", (Delegate)HandleExport);
return endpoints;
}
// ─────────────────────────────────────────────────────────────────────
// GET /api/audit/query
// ─────────────────────────────────────────────────────────────────────
internal static async Task<IResult> HandleQuery(HttpContext context)
{
var auth = await AuthenticateAsync(context);
if (auth.Failure is not null)
{
return auth.Failure;
}
if (!HasAnyRole(auth.User!, AuthorizationPolicies.OperationalAuditRoles))
{
return Forbidden("OperationalAudit");
}
var filter = ParseFilter(context.Request.Query);
var paging = ParsePaging(context.Request.Query);
var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();
var events = await repo.QueryAsync(filter, paging, context.RequestAborted);
// The cursor for the next page is the last row of this page — but only
// when the page came back FULL. A short page means there is no next
// page, so nextCursor is null and the CLI stops paging.
object? nextCursor = null;
if (events.Count == paging.PageSize && events.Count > 0)
{
var last = events[^1];
nextCursor = new
{
afterOccurredAtUtc = last.OccurredAtUtc,
afterEventId = last.EventId,
};
}
var payload = new { events, nextCursor };
// EnvelopeJsonOptions keeps an explicit null nextCursor on the wire so
// the CLI can always read the key. AuditEvent rows render with their
// full (null-inclusive) shape — a stable schema for the consumer.
return Results.Text(
JsonSerializer.Serialize(payload, EnvelopeJsonOptions), "application/json", statusCode: 200);
}
// ─────────────────────────────────────────────────────────────────────
// GET /api/audit/export
// ─────────────────────────────────────────────────────────────────────
internal static async Task<IResult> HandleExport(HttpContext context)
{
var auth = await AuthenticateAsync(context);
if (auth.Failure is not null)
{
return auth.Failure;
}
if (!HasAnyRole(auth.User!, AuthorizationPolicies.AuditExportRoles))
{
return Forbidden("AuditExport");
}
var format = (context.Request.Query["format"].ToString() ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrEmpty(format))
{
format = "csv";
}
if (format == "parquet")
{
// Deferred to v1.x (Component-AuditLog.md §"Deferred"): no Parquet
// library is in the solution. Return a 501 with actionable guidance
// rather than a partial binary stream.
return Results.Json(
new { error = "Parquet export deferred to v1.x; use csv or jsonl.", code = "NOT_IMPLEMENTED" },
statusCode: 501);
}
if (format != "csv" && format != "jsonl")
{
return Results.Json(
new { error = $"Unsupported export format '{format}'. Use csv, jsonl, or parquet.", code = "BAD_REQUEST" },
statusCode: 400);
}
var filter = ParseFilter(context.Request.Query);
var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();
var contentType = format == "csv" ? "text/csv; charset=utf-8" : "application/x-ndjson";
var extension = format == "csv" ? "csv" : "jsonl";
var fileName = $"audit-log-{DateTime.UtcNow:yyyyMMdd-HHmmss}.{extension}";
context.Response.ContentType = contentType;
context.Response.Headers["Content-Disposition"] = $"attachment; filename=\"{fileName}\"";
// Defeat intermediate proxy buffering so rows stream as each page flushes.
context.Response.Headers["Cache-Control"] = "no-store";
if (format == "csv")
{
await StreamCsvAsync(repo, filter, context.Response.Body, context.RequestAborted);
}
else
{
await StreamJsonlAsync(repo, filter, context.Response.Body, context.RequestAborted);
}
return Results.Empty;
}
/// <summary>
/// Streams every matching row as RFC 4180 CSV, paging the repository with its
/// keyset cursor and flushing after each page so a large export starts
/// arriving immediately. Column order matches <see cref="AuditEvent"/>.
/// </summary>
private static async Task StreamCsvAsync(
IAuditLogRepository repo, AuditLogQueryFilter filter, Stream output, CancellationToken ct)
{
await using var writer = new StreamWriter(
output,
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
bufferSize: 4096,
leaveOpen: true)
{
NewLine = "\r\n",
};
await writer.WriteLineAsync(CsvHeader);
await foreach (var page in PageAsync(repo, filter, ct))
{
foreach (var evt in page)
{
await writer.WriteLineAsync(FormatCsvRow(evt));
}
await writer.FlushAsync(ct);
await output.FlushAsync(ct);
}
}
/// <summary>
/// Streams every matching row as newline-delimited JSON (one compact object
/// per line), flushing after each page.
/// </summary>
private static async Task StreamJsonlAsync(
IAuditLogRepository repo, AuditLogQueryFilter filter, Stream output, CancellationToken ct)
{
await using var writer = new StreamWriter(
output,
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
bufferSize: 4096,
leaveOpen: true)
{
NewLine = "\n",
};
await foreach (var page in PageAsync(repo, filter, ct))
{
foreach (var evt in page)
{
await writer.WriteLineAsync(JsonSerializer.Serialize(evt, JsonOptions));
}
await writer.FlushAsync(ct);
await output.FlushAsync(ct);
}
}
/// <summary>
/// Lazily yields full repository pages, advancing the keyset cursor until a
/// short page signals the end. Shared by the csv + jsonl streamers.
/// </summary>
private static async IAsyncEnumerable<IReadOnlyList<AuditEvent>> PageAsync(
IAuditLogRepository repo,
AuditLogQueryFilter filter,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
var cursor = new AuditLogPaging(ExportPageSize);
while (true)
{
ct.ThrowIfCancellationRequested();
var page = await repo.QueryAsync(filter, cursor, ct);
if (page.Count == 0)
{
yield break;
}
yield return page;
if (page.Count < cursor.PageSize)
{
yield break;
}
var last = page[^1];
cursor = new AuditLogPaging(ExportPageSize, last.OccurredAtUtc, last.EventId);
}
}
// ─────────────────────────────────────────────────────────────────────
// Authentication — Basic Auth → LDAP → roles (mirrors ManagementEndpoints)
// ─────────────────────────────────────────────────────────────────────
/// <summary>Outcome of <see cref="AuthenticateAsync"/>: exactly one of the two fields is set.</summary>
private readonly record struct AuthOutcome(AuthenticatedUser? User, IResult? Failure);
/// <summary>
/// Decodes HTTP Basic Auth, binds against LDAP, and resolves roles — the same
/// flow <see cref="ManagementEndpoints"/> uses. Returns a populated
/// <see cref="AuthenticatedUser"/> on success, or an <see cref="IResult"/>
/// carrying the 401 response on any failure.
/// </summary>
private static async Task<AuthOutcome> AuthenticateAsync(HttpContext context)
{
var authHeader = context.Request.Headers.Authorization.ToString();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
{
return new AuthOutcome(null, Results.Json(
new { error = "Authorization header required (Basic scheme).", code = "AUTH_FAILED" }, statusCode: 401));
}
string username, password;
try
{
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader["Basic ".Length..]));
var colon = decoded.IndexOf(':');
if (colon < 0) throw new FormatException();
username = decoded[..colon];
password = decoded[(colon + 1)..];
}
catch
{
return new AuthOutcome(null, Results.Json(
new { error = "Malformed Basic Auth header.", code = "AUTH_FAILED" }, statusCode: 401));
}
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return new AuthOutcome(null, Results.Json(
new { error = "Username and password are required.", code = "AUTH_FAILED" }, statusCode: 401));
}
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
var authResult = await ldapAuth.AuthenticateAsync(username, password);
if (!authResult.Success)
{
return new AuthOutcome(null, Results.Json(
new { error = authResult.ErrorMessage ?? "Authentication failed.", code = "AUTH_FAILED" }, statusCode: 401));
}
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
var mappingResult = await roleMapper.MapGroupsToRolesAsync(
authResult.Groups ?? (IReadOnlyList<string>)Array.Empty<string>());
var permittedSiteIds = mappingResult.IsSystemWideDeployment
? Array.Empty<string>()
: mappingResult.PermittedSiteIds.ToArray();
var user = new AuthenticatedUser(
authResult.Username!,
authResult.DisplayName!,
mappingResult.Roles.ToArray(),
permittedSiteIds);
return new AuthOutcome(user, null);
}
private static bool HasAnyRole(AuthenticatedUser user, string[] allowed) =>
user.Roles.Any(r => allowed.Contains(r, StringComparer.OrdinalIgnoreCase));
private static IResult Forbidden(string permission) => Results.Json(
new { error = $"Permission '{permission}' required.", code = "UNAUTHORIZED" }, statusCode: 403);
// ─────────────────────────────────────────────────────────────────────
// Query-string parsing
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. Unknown
/// enum names / un-parseable Guids / dates are silently dropped (no 400) —
/// the same lax contract the CentralUI export endpoint uses.
/// </summary>
public static AuditLogQueryFilter ParseFilter(IQueryCollection query)
{
AuditChannel? channel = null;
if (query.TryGetValue("channel", out var channelValues)
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
{
channel = parsedChannel;
}
AuditKind? kind = null;
if (query.TryGetValue("kind", out var kindValues)
&& Enum.TryParse<AuditKind>(kindValues.ToString(), ignoreCase: true, out var parsedKind))
{
kind = parsedKind;
}
AuditStatus? status = null;
if (query.TryGetValue("status", out var statusValues)
&& Enum.TryParse<AuditStatus>(statusValues.ToString(), ignoreCase: true, out var parsedStatus))
{
status = parsedStatus;
}
Guid? correlationId = null;
if (query.TryGetValue("correlationId", out var corrValues)
&& Guid.TryParse(corrValues.ToString(), out var parsedCorr))
{
correlationId = parsedCorr;
}
return new AuditLogQueryFilter(
Channel: channel,
Kind: kind,
Status: status,
SourceSiteId: TrimToNullable(query, "sourceSiteId"),
Target: TrimToNullable(query, "target"),
Actor: TrimToNullable(query, "actor"),
CorrelationId: correlationId,
FromUtc: ParseUtcDate(query, "fromUtc"),
ToUtc: ParseUtcDate(query, "toUtc"));
}
/// <summary>
/// Parses the keyset-paging query parameters into an
/// <see cref="AuditLogPaging"/>. <c>pageSize</c> is clamped to
/// <c>[1, <see cref="MaxPageSize"/>]</c>; a missing / junk value falls back
/// to <see cref="DefaultPageSize"/>. <c>afterOccurredAtUtc</c> +
/// <c>afterEventId</c> are honoured only when BOTH parse (the repository's
/// keyset cursor requires the pair together).
/// </summary>
public static AuditLogPaging ParsePaging(IQueryCollection query)
{
int pageSize = DefaultPageSize;
if (query.TryGetValue("pageSize", out var pageSizeRaw)
&& int.TryParse(pageSizeRaw.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedSize)
&& parsedSize > 0)
{
pageSize = Math.Min(parsedSize, MaxPageSize);
}
DateTime? afterOccurredAt = ParseUtcDate(query, "afterOccurredAtUtc");
Guid? afterEventId = null;
if (query.TryGetValue("afterEventId", out var eventIdRaw)
&& Guid.TryParse(eventIdRaw.ToString(), out var parsedEventId))
{
afterEventId = parsedEventId;
}
// The cursor pair must be all-or-nothing — a half-supplied cursor would
// produce an invalid keyset predicate. Drop both unless both are present.
if (afterOccurredAt is null || afterEventId is null)
{
return new AuditLogPaging(pageSize);
}
return new AuditLogPaging(pageSize, afterOccurredAt, afterEventId);
}
private static string? TrimToNullable(IQueryCollection query, string key)
{
if (!query.TryGetValue(key, out var values))
{
return null;
}
var v = values.ToString();
return string.IsNullOrWhiteSpace(v) ? null : v.Trim();
}
private static DateTime? ParseUtcDate(IQueryCollection query, string key)
{
if (!query.TryGetValue(key, out var values))
{
return null;
}
if (DateTime.TryParse(
values.ToString(),
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed))
{
return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
}
return null;
}
// ─────────────────────────────────────────────────────────────────────
// CSV helpers — 21 columns in AuditEvent declaration order
// ─────────────────────────────────────────────────────────────────────
public const string CsvHeader =
"EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId," +
"SourceSiteId,SourceInstanceId,SourceScript,Actor,Target,Status," +
"HttpStatus,DurationMs,ErrorMessage,ErrorDetail,RequestSummary," +
"ResponseSummary,PayloadTruncated,Extra,ForwardState";
public static string FormatCsvRow(AuditEvent evt)
{
var sb = new StringBuilder(256);
AppendField(sb, evt.EventId.ToString(), first: true);
AppendField(sb, FormatDate(evt.OccurredAtUtc));
AppendField(sb, FormatDate(evt.IngestedAtUtc));
AppendField(sb, evt.Channel.ToString());
AppendField(sb, evt.Kind.ToString());
AppendField(sb, evt.CorrelationId?.ToString());
AppendField(sb, evt.SourceSiteId);
AppendField(sb, evt.SourceInstanceId);
AppendField(sb, evt.SourceScript);
AppendField(sb, evt.Actor);
AppendField(sb, evt.Target);
AppendField(sb, evt.Status.ToString());
AppendField(sb, evt.HttpStatus?.ToString(CultureInfo.InvariantCulture));
AppendField(sb, evt.DurationMs?.ToString(CultureInfo.InvariantCulture));
AppendField(sb, evt.ErrorMessage);
AppendField(sb, evt.ErrorDetail);
AppendField(sb, evt.RequestSummary);
AppendField(sb, evt.ResponseSummary);
AppendField(sb, evt.PayloadTruncated.ToString());
AppendField(sb, evt.Extra);
AppendField(sb, evt.ForwardState?.ToString());
return sb.ToString();
}
private static string? FormatDate(DateTime? value) =>
value?.ToString("O", CultureInfo.InvariantCulture);
private static string FormatDate(DateTime value) =>
value.ToString("O", CultureInfo.InvariantCulture);
private static void AppendField(StringBuilder sb, string? value, bool first = false)
{
if (!first) sb.Append(',');
if (string.IsNullOrEmpty(value))
{
return;
}
// RFC 4180: quote on comma / quote / CR / LF; double-up embedded quotes.
if (value.IndexOfAny(s_quoteTriggers) < 0)
{
sb.Append(value);
return;
}
sb.Append('"');
foreach (var ch in value)
{
if (ch == '"')
{
sb.Append('"').Append('"');
}
else
{
sb.Append(ch);
}
}
sb.Append('"');
}
private static readonly char[] s_quoteTriggers = { ',', '"', '\r', '\n' };
}

View File

@@ -86,14 +86,25 @@ public static class AuthorizationPolicies
/// Roles that satisfy <see cref="OperationalAudit"/>. Held in one place
/// so the seed/docs and the policy stay in lockstep.
/// </summary>
internal static readonly string[] OperationalAuditRoles = { "Admin", "Audit", "AuditReadOnly" };
/// <remarks>
/// Public so the ManagementService HTTP API (#23 M8) — which gates the
/// <c>/api/audit/*</c> routes with a manual Basic-Auth + LDAP role check
/// rather than the ASP.NET authorization-policy pipeline — can reuse the
/// exact same role set the <see cref="OperationalAudit"/> policy enforces.
/// </remarks>
public static readonly string[] OperationalAuditRoles = { "Admin", "Audit", "AuditReadOnly" };
/// <summary>
/// Roles that satisfy <see cref="AuditExport"/>. A strict subset of
/// <see cref="OperationalAuditRoles"/> — read access does NOT imply
/// export permission.
/// </summary>
internal static readonly string[] AuditExportRoles = { "Admin", "Audit" };
/// <remarks>
/// Public for the same reason as <see cref="OperationalAuditRoles"/> —
/// the ManagementService <c>/api/audit/export</c> route checks roles
/// against this set directly.
/// </remarks>
public static readonly string[] AuditExportRoles = { "Admin", "Audit" };
public static IServiceCollection AddScadaLinkAuthorization(this IServiceCollection services)
{

View File

@@ -15,7 +15,9 @@ public class LdapAuthService
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
// virtual: a test seam so HTTP-pipeline tests (e.g. the #23 M8 audit
// endpoints) can substitute the LDAP bind without standing up a directory.
public virtual async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(username))
return new LdapAuthResult(false, null, null, null, "Username is required.");

View File

@@ -11,7 +11,9 @@ public class RoleMapper
_securityRepository = securityRepository ?? throw new ArgumentNullException(nameof(securityRepository));
}
public async Task<RoleMappingResult> MapGroupsToRolesAsync(
// virtual: a test seam so HTTP-pipeline tests (e.g. the #23 M8 audit
// endpoints) can substitute the LDAP-group→role resolution.
public virtual async Task<RoleMappingResult> MapGroupsToRolesAsync(
IReadOnlyList<string> ldapGroups,
CancellationToken ct = default)
{