From a1bdd94d4cdb087e910708593fcf042bb214a7d4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 21:49:14 -0400 Subject: [PATCH] feat(mgmt): /api/audit/{query,export} endpoints with permission gates (#23 M8) --- src/ScadaLink.Host/Program.cs | 4 + .../AuditEndpoints.cs | 554 ++++++++++++++++++ .../AuthorizationPolicies.cs | 15 +- src/ScadaLink.Security/LdapAuthService.cs | 4 +- src/ScadaLink.Security/RoleMapper.cs | 4 +- .../AuditEndpointsTests.cs | 386 ++++++++++++ .../ScadaLink.ManagementService.Tests.csproj | 5 + 7 files changed, 968 insertions(+), 4 deletions(-) create mode 100644 src/ScadaLink.ManagementService/AuditEndpoints.cs create mode 100644 tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs diff --git a/src/ScadaLink.Host/Program.cs b/src/ScadaLink.Host/Program.cs index 3632824..a0b44a6 100644 --- a/src/ScadaLink.Host/Program.cs +++ b/src/ScadaLink.Host/Program.cs @@ -201,6 +201,10 @@ try app.MapCentralUI(); 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("/hubs/debug-stream"); // Compile and register all Inbound API method scripts at startup diff --git a/src/ScadaLink.ManagementService/AuditEndpoints.cs b/src/ScadaLink.ManagementService/AuditEndpoints.cs new file mode 100644 index 0000000..18217eb --- /dev/null +++ b/src/ScadaLink.ManagementService/AuditEndpoints.cs @@ -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; + +/// +/// Minimal-API endpoints exposing the central Audit Log (#23) over HTTP for the +/// ScadaLink CLI (M8). Two routes: +/// +/// GET /api/audit/query — keyset-paged JSON page, gated on the +/// permission. +/// GET /api/audit/export — streamed bulk export (csv / jsonl; +/// parquet returns HTTP 501), gated on the +/// permission. +/// +/// +/// +/// Auth mechanism. ManagementService ships NO ASP.NET authorization-policy +/// pipeline — the existing /management endpoint +/// () authenticates each request by hand: +/// decode HTTP Basic Auth, bind against LDAP via , +/// then map LDAP groups to roles via . These audit +/// endpoints follow that exact mechanism rather than .RequireAuthorization() +/// 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 / +/// — the very role sets the +/// CentralUI's enforces, so the two surfaces +/// stay in lockstep. +/// +/// +/// +/// Parquet. No Parquet library is referenced by the solution and +/// Component-AuditLog.md explicitly defers Parquet archival to v1.x, so +/// format=parquet returns 501 Not Implemented with a guidance +/// message rather than a half-built binary stream. csv + jsonl are fully +/// implemented. +/// +/// +public static class AuditEndpoints +{ + /// Default rows per page for /api/audit/query. + public const int DefaultPageSize = 100; + + /// Hard ceiling on a single query page — keeps one page bounded in memory. + public const int MaxPageSize = 1000; + + /// Repository round-trip size used while streaming an export. + public const int ExportPageSize = 1000; + + /// + /// Serializer for individual rows (query results + + /// jsonl export lines). Drops null columns so a sparse audit row stays + /// compact on the wire. + /// + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() }, + }; + + /// + /// Serializer for the /api/audit/query response envelope. Unlike + /// it does NOT ignore nulls — the contract + /// requires nextCursor to be present as an explicit null when + /// there is no further page, so the CLI can rely on the key always existing. + /// + 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 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(); + 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 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(); + + 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; + } + + /// + /// 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 . + /// + 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); + } + } + + /// + /// Streams every matching row as newline-delimited JSON (one compact object + /// per line), flushing after each page. + /// + 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); + } + } + + /// + /// Lazily yields full repository pages, advancing the keyset cursor until a + /// short page signals the end. Shared by the csv + jsonl streamers. + /// + private static async IAsyncEnumerable> 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) + // ───────────────────────────────────────────────────────────────────── + + /// Outcome of : exactly one of the two fields is set. + private readonly record struct AuthOutcome(AuthenticatedUser? User, IResult? Failure); + + /// + /// Decodes HTTP Basic Auth, binds against LDAP, and resolves roles — the same + /// flow uses. Returns a populated + /// on success, or an + /// carrying the 401 response on any failure. + /// + private static async Task 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(); + 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(); + var mappingResult = await roleMapper.MapGroupsToRolesAsync( + authResult.Groups ?? (IReadOnlyList)Array.Empty()); + + var permittedSiteIds = mappingResult.IsSystemWideDeployment + ? Array.Empty() + : 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 + // ───────────────────────────────────────────────────────────────────── + + /// + /// Parses the query-string into an . Unknown + /// enum names / un-parseable Guids / dates are silently dropped (no 400) — + /// the same lax contract the CentralUI export endpoint uses. + /// + public static AuditLogQueryFilter ParseFilter(IQueryCollection query) + { + AuditChannel? channel = null; + if (query.TryGetValue("channel", out var channelValues) + && Enum.TryParse(channelValues.ToString(), ignoreCase: true, out var parsedChannel)) + { + channel = parsedChannel; + } + + AuditKind? kind = null; + if (query.TryGetValue("kind", out var kindValues) + && Enum.TryParse(kindValues.ToString(), ignoreCase: true, out var parsedKind)) + { + kind = parsedKind; + } + + AuditStatus? status = null; + if (query.TryGetValue("status", out var statusValues) + && Enum.TryParse(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")); + } + + /// + /// Parses the keyset-paging query parameters into an + /// . pageSize is clamped to + /// [1, ]; a missing / junk value falls back + /// to . afterOccurredAtUtc + + /// afterEventId are honoured only when BOTH parse (the repository's + /// keyset cursor requires the pair together). + /// + 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' }; +} diff --git a/src/ScadaLink.Security/AuthorizationPolicies.cs b/src/ScadaLink.Security/AuthorizationPolicies.cs index 200c778..9e19b4f 100644 --- a/src/ScadaLink.Security/AuthorizationPolicies.cs +++ b/src/ScadaLink.Security/AuthorizationPolicies.cs @@ -86,14 +86,25 @@ public static class AuthorizationPolicies /// Roles that satisfy . Held in one place /// so the seed/docs and the policy stay in lockstep. /// - internal static readonly string[] OperationalAuditRoles = { "Admin", "Audit", "AuditReadOnly" }; + /// + /// Public so the ManagementService HTTP API (#23 M8) — which gates the + /// /api/audit/* 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 policy enforces. + /// + public static readonly string[] OperationalAuditRoles = { "Admin", "Audit", "AuditReadOnly" }; /// /// Roles that satisfy . A strict subset of /// — read access does NOT imply /// export permission. /// - internal static readonly string[] AuditExportRoles = { "Admin", "Audit" }; + /// + /// Public for the same reason as — + /// the ManagementService /api/audit/export route checks roles + /// against this set directly. + /// + public static readonly string[] AuditExportRoles = { "Admin", "Audit" }; public static IServiceCollection AddScadaLinkAuthorization(this IServiceCollection services) { diff --git a/src/ScadaLink.Security/LdapAuthService.cs b/src/ScadaLink.Security/LdapAuthService.cs index 1b8b161..48ad10e 100644 --- a/src/ScadaLink.Security/LdapAuthService.cs +++ b/src/ScadaLink.Security/LdapAuthService.cs @@ -15,7 +15,9 @@ public class LdapAuthService _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public async Task 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 AuthenticateAsync(string username, string password, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(username)) return new LdapAuthResult(false, null, null, null, "Username is required."); diff --git a/src/ScadaLink.Security/RoleMapper.cs b/src/ScadaLink.Security/RoleMapper.cs index 4ab88b8..f59bc4e 100644 --- a/src/ScadaLink.Security/RoleMapper.cs +++ b/src/ScadaLink.Security/RoleMapper.cs @@ -11,7 +11,9 @@ public class RoleMapper _securityRepository = securityRepository ?? throw new ArgumentNullException(nameof(securityRepository)); } - public async Task 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 MapGroupsToRolesAsync( IReadOnlyList ldapGroups, CancellationToken ct = default) { diff --git a/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs b/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs new file mode 100644 index 0000000..ee2f0a3 --- /dev/null +++ b/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs @@ -0,0 +1,386 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.ManagementService; +using ScadaLink.Security; + +namespace ScadaLink.ManagementService.Tests; + +/// +/// HTTP-pipeline tests for the #23 M8 audit endpoints (). +/// +/// +/// ManagementService authenticates each request by hand (HTTP Basic → LDAP → +/// roles) rather than via the ASP.NET authorization-policy pipeline, so these +/// tests substitute + +/// (both expose a virtual test seam) to drive the role outcome without standing +/// up a directory. The repository is a stubbed . +/// +/// +public class AuditEndpointsTests +{ + private const string BasicCredential = "auditor:password"; + + private static AuditEvent SampleEvent(Guid? id = null, DateTime? occurredAt = null) => new() + { + EventId = id ?? Guid.Parse("11111111-1111-1111-1111-111111111111"), + OccurredAtUtc = occurredAt ?? new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + SourceSiteId = "plant-a", + Status = AuditStatus.Delivered, + HttpStatus = 200, + }; + + /// + /// Builds an in-process TestServer hosting the audit endpoints with stubbed + /// auth + repository. is the role set the + /// substituted returns for the authenticated user; + /// pass an empty array to simulate a user with no audit permission. + /// + private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostAsync( + string[] roles, + IReadOnlyList[]? queryPages = null, + bool ldapSucceeds = true) + { + var repo = Substitute.For(); + if (queryPages is { Length: > 0 }) + { + var returns = queryPages + .Select(p => Task.FromResult>(p)) + .ToArray(); + repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(returns[0], returns.Skip(1).ToArray()); + } + else + { + repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + } + + // Substituted LDAP bind — AuthenticateAsync is virtual (test seam). + var ldap = Substitute.For( + Options.Create(new SecurityOptions()), + Substitute.For>()); + ldap.AuthenticateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ldapSucceeds + ? new LdapAuthResult(true, "Auditor", "auditor", new[] { "cn=audit" }, null) + : new LdapAuthResult(false, null, null, null, "Bad credentials.")); + + // Substituted role mapper — MapGroupsToRolesAsync is virtual (test seam). + var roleMapper = Substitute.For(Substitute.For()); + roleMapper.MapGroupsToRolesAsync(Arg.Any>(), Arg.Any()) + .Returns(new RoleMappingResult(roles, Array.Empty(), IsSystemWideDeployment: true)); + + var hostBuilder = new HostBuilder() + .ConfigureWebHost(web => + { + web.UseTestServer(); + web.ConfigureServices(services => + { + services.AddRouting(); + services.AddSingleton(repo); + services.AddSingleton(ldap); + services.AddSingleton(roleMapper); + }); + web.Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => endpoints.MapAuditAPI()); + }); + }); + + var host = await hostBuilder.StartAsync(); + return (host.GetTestClient(), repo, host); + } + + private static HttpRequestMessage Get(string url, string credential = BasicCredential) + { + var request = new HttpRequestMessage(HttpMethod.Get, url); + if (credential.Length > 0) + { + request.Headers.Authorization = new AuthenticationHeaderValue( + "Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(credential))); + } + return request; + } + + // ───────────────────────────────────────────────────────────────────── + // /api/audit/query + // ───────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Query_ValidParams_ReturnsJsonPage() + { + var (client, _, host) = await BuildHostAsync( + roles: new[] { "Audit" }, + queryPages: new[] { (IReadOnlyList)new[] { SampleEvent() } }); + using (host) + { + var response = await client.SendAsync(Get( + "/api/audit/query?channel=ApiOutbound&status=Delivered&pageSize=100")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var events = doc.RootElement.GetProperty("events"); + Assert.Equal(1, events.GetArrayLength()); + Assert.Equal("11111111-1111-1111-1111-111111111111", + events[0].GetProperty("eventId").GetString()); + + // A short page (1 row < pageSize 100) means no further pages. + Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("nextCursor").ValueKind); + } + } + + [Fact] + public async Task Query_WithCursor_ReturnsNextPage() + { + // First page is FULL (pageSize=2 → 2 rows) so the response carries a + // non-null nextCursor; the test then replays that cursor as the next + // request and asserts the repo saw a keyset-paged AuditLogPaging. + var pageOne = (IReadOnlyList)new[] + { + SampleEvent(Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"), + new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc)), + SampleEvent(Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002"), + new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc)), + }; + var (client, repo, host) = await BuildHostAsync( + roles: new[] { "Audit" }, + queryPages: new[] { pageOne }); + using (host) + { + var first = await client.SendAsync(Get("/api/audit/query?pageSize=2")); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + + using var firstDoc = JsonDocument.Parse(await first.Content.ReadAsStringAsync()); + var cursor = firstDoc.RootElement.GetProperty("nextCursor"); + Assert.Equal(JsonValueKind.Object, cursor.ValueKind); + + var afterEventId = cursor.GetProperty("afterEventId").GetString()!; + var afterOccurredAt = cursor.GetProperty("afterOccurredAtUtc").GetString()!; + Assert.Equal("aaaaaaaa-0000-0000-0000-000000000002", afterEventId); + + // Replay the cursor — the endpoint must thread it into AuditLogPaging. + var second = await client.SendAsync(Get( + $"/api/audit/query?pageSize=2&afterEventId={afterEventId}&afterOccurredAtUtc={Uri.EscapeDataString(afterOccurredAt)}")); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + + await repo.Received().QueryAsync( + Arg.Any(), + Arg.Is(p => + p.PageSize == 2 && + p.AfterEventId == Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002") && + p.AfterOccurredAtUtc != null), + Arg.Any()); + } + } + + [Fact] + public async Task Query_WithoutOperationalAudit_Returns403() + { + // A user whose only role is Design holds neither OperationalAudit nor + // AuditExport — the query endpoint must 403. + var (client, _, host) = await BuildHostAsync(roles: new[] { "Design" }); + using (host) + { + var response = await client.SendAsync(Get("/api/audit/query")); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + } + + [Fact] + public async Task Query_WithoutCredentials_Returns401() + { + var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" }); + using (host) + { + var response = await client.SendAsync(Get("/api/audit/query", credential: "")); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + } + + [Fact] + public async Task Query_AuditReadOnlyRole_IsAllowed() + { + // AuditReadOnly satisfies OperationalAudit (read) — query must succeed. + var (client, _, host) = await BuildHostAsync(roles: new[] { "AuditReadOnly" }); + using (host) + { + var response = await client.SendAsync(Get("/api/audit/query")); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + } + + // ───────────────────────────────────────────────────────────────────── + // /api/audit/export + // ───────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Export_Csv_StreamsContent_WithCsvContentType() + { + var (client, _, host) = await BuildHostAsync( + roles: new[] { "Audit" }, + queryPages: new[] + { + (IReadOnlyList)new[] { SampleEvent() }, + (IReadOnlyList)Array.Empty(), + }); + using (host) + { + var response = await client.SendAsync(Get("/api/audit/export?format=csv")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType); + + var disposition = response.Content.Headers.ContentDisposition; + Assert.NotNull(disposition); + Assert.Equal("attachment", disposition!.DispositionType); + Assert.EndsWith(".csv", disposition.FileName, StringComparison.OrdinalIgnoreCase); + + var body = await response.Content.ReadAsStringAsync(); + Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,", body); + Assert.Contains("11111111-1111-1111-1111-111111111111", body); + } + } + + [Fact] + public async Task Export_Csv_DefaultsWhenFormatOmitted() + { + // No format= param → csv default. + var (client, _, host) = await BuildHostAsync( + roles: new[] { "Audit" }, + queryPages: new[] { (IReadOnlyList)Array.Empty() }); + using (host) + { + var response = await client.SendAsync(Get("/api/audit/export")); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType); + } + } + + [Fact] + public async Task Export_Jsonl_StreamsOnePerLine() + { + var (client, _, host) = await BuildHostAsync( + roles: new[] { "Audit" }, + queryPages: new[] + { + (IReadOnlyList)new[] + { + SampleEvent(Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001")), + SampleEvent(Guid.Parse("bbbbbbbb-0000-0000-0000-000000000002")), + }, + (IReadOnlyList)Array.Empty(), + }); + using (host) + { + var response = await client.SendAsync(Get("/api/audit/export?format=jsonl")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/x-ndjson", response.Content.Headers.ContentType!.MediaType); + + var body = await response.Content.ReadAsStringAsync(); + var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(2, lines.Length); + + // Each line must be a standalone, parseable JSON object. + foreach (var line in lines) + { + using var doc = JsonDocument.Parse(line); + Assert.Equal(JsonValueKind.Object, doc.RootElement.ValueKind); + Assert.True(doc.RootElement.TryGetProperty("eventId", out _)); + } + } + } + + [Fact] + public async Task Export_Parquet_Returns501() + { + // Parquet archival is deferred to v1.x (Component-AuditLog.md) — no + // library is referenced, so the endpoint returns 501 with guidance. + var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" }); + using (host) + { + var response = await client.SendAsync(Get("/api/audit/export?format=parquet")); + + Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("Parquet export deferred to v1.x", body); + } + } + + [Fact] + public async Task Export_WithoutAuditExport_Returns403() + { + // AuditReadOnly grants read (OperationalAudit) but NOT bulk export + // (AuditExport) — the export endpoint must 403. + var (client, _, host) = await BuildHostAsync(roles: new[] { "AuditReadOnly" }); + using (host) + { + var response = await client.SendAsync(Get("/api/audit/export?format=csv")); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + } + + [Fact] + public async Task Export_UnsupportedFormat_Returns400() + { + var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" }); + using (host) + { + var response = await client.SendAsync(Get("/api/audit/export?format=xml")); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + } + + // ───────────────────────────────────────────────────────────────────── + // Query-string parsing units + // ───────────────────────────────────────────────────────────────────── + + [Fact] + public void ParsePaging_ClampsPageSizeToMax() + { + var query = new Microsoft.AspNetCore.Http.QueryCollection( + new Dictionary + { + ["pageSize"] = "999999", + }); + + var paging = AuditEndpoints.ParsePaging(query); + + Assert.Equal(AuditEndpoints.MaxPageSize, paging.PageSize); + } + + [Fact] + public void ParsePaging_HalfSuppliedCursor_IsDropped() + { + // afterEventId without afterOccurredAtUtc is an invalid keyset cursor — + // both must be dropped so the repository gets a first-page request. + var query = new Microsoft.AspNetCore.Http.QueryCollection( + new Dictionary + { + ["afterEventId"] = Guid.NewGuid().ToString(), + }); + + var paging = AuditEndpoints.ParsePaging(query); + + Assert.Null(paging.AfterEventId); + Assert.Null(paging.AfterOccurredAtUtc); + } +} diff --git a/tests/ScadaLink.ManagementService.Tests/ScadaLink.ManagementService.Tests.csproj b/tests/ScadaLink.ManagementService.Tests/ScadaLink.ManagementService.Tests.csproj index 1df5a3c..f1f131b 100644 --- a/tests/ScadaLink.ManagementService.Tests/ScadaLink.ManagementService.Tests.csproj +++ b/tests/ScadaLink.ManagementService.Tests/ScadaLink.ManagementService.Tests.csproj @@ -6,9 +6,13 @@ true false + + + + @@ -20,5 +24,6 @@ +