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 . The /// channel/kind/status/sourceSiteId dimensions are /// multi-value: a repeated query param (channel=A&channel=B) yields /// a multi-element filter list, while a single param yields a one-element /// list. Unknown enum names / un-parseable Guids / dates are silently dropped /// (no 400) — the same lax contract the CentralUI export endpoint uses; an /// unparseable value within a repeated set is dropped, not the whole set. /// /// /// This endpoint reads the source-site filter from the sourceSiteId /// query key, whereas the CentralUI export endpoint reads it as site. /// The divergence is deliberate — each endpoint matches its own CLI / UI URL /// builder — so do NOT "fix" the two to a single key name. /// public static AuditLogQueryFilter ParseFilter(IQueryCollection query) { var channels = AuditQueryParamParsers.ParseEnumList(query["channel"]); var kinds = AuditQueryParamParsers.ParseEnumList(query["kind"]); var statuses = AuditQueryParamParsers.ParseEnumList(query["status"]); var sourceSiteIds = AuditQueryParamParsers.ParseStringList(query["sourceSiteId"]); Guid? correlationId = null; if (query.TryGetValue("correlationId", out var corrValues) && Guid.TryParse(corrValues.ToString(), out var parsedCorr)) { correlationId = parsedCorr; } return new AuditLogQueryFilter( Channels: channels, Kinds: kinds, Statuses: statuses, SourceSiteIds: sourceSiteIds, 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' }; }