feat(mgmt): /api/audit/{query,export} endpoints with permission gates (#23 M8)
This commit is contained in:
@@ -201,6 +201,10 @@ try
|
|||||||
app.MapCentralUI<ScadaLink.Host.Components.App>();
|
app.MapCentralUI<ScadaLink.Host.Components.App>();
|
||||||
app.MapInboundAPI();
|
app.MapInboundAPI();
|
||||||
app.MapManagementAPI();
|
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");
|
app.MapHub<ScadaLink.ManagementService.DebugStreamHub>("/hubs/debug-stream");
|
||||||
|
|
||||||
// Compile and register all Inbound API method scripts at startup
|
// Compile and register all Inbound API method scripts at startup
|
||||||
|
|||||||
554
src/ScadaLink.ManagementService/AuditEndpoints.cs
Normal file
554
src/ScadaLink.ManagementService/AuditEndpoints.cs
Normal 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' };
|
||||||
|
}
|
||||||
@@ -86,14 +86,25 @@ public static class AuthorizationPolicies
|
|||||||
/// Roles that satisfy <see cref="OperationalAudit"/>. Held in one place
|
/// Roles that satisfy <see cref="OperationalAudit"/>. Held in one place
|
||||||
/// so the seed/docs and the policy stay in lockstep.
|
/// so the seed/docs and the policy stay in lockstep.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Roles that satisfy <see cref="AuditExport"/>. A strict subset of
|
/// Roles that satisfy <see cref="AuditExport"/>. A strict subset of
|
||||||
/// <see cref="OperationalAuditRoles"/> — read access does NOT imply
|
/// <see cref="OperationalAuditRoles"/> — read access does NOT imply
|
||||||
/// export permission.
|
/// export permission.
|
||||||
/// </summary>
|
/// </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)
|
public static IServiceCollection AddScadaLinkAuthorization(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ public class LdapAuthService
|
|||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_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))
|
if (string.IsNullOrWhiteSpace(username))
|
||||||
return new LdapAuthResult(false, null, null, null, "Username is required.");
|
return new LdapAuthResult(false, null, null, null, "Username is required.");
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ public class RoleMapper
|
|||||||
_securityRepository = securityRepository ?? throw new ArgumentNullException(nameof(securityRepository));
|
_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,
|
IReadOnlyList<string> ldapGroups,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
386
tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs
Normal file
386
tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP-pipeline tests for the #23 M8 audit endpoints (<see cref="AuditEndpoints"/>).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// ManagementService authenticates each request by hand (HTTP Basic → LDAP →
|
||||||
|
/// roles) rather than via the ASP.NET authorization-policy pipeline, so these
|
||||||
|
/// tests substitute <see cref="LdapAuthService"/> + <see cref="RoleMapper"/>
|
||||||
|
/// (both expose a virtual test seam) to drive the role outcome without standing
|
||||||
|
/// up a directory. The repository is a stubbed <see cref="IAuditLogRepository"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an in-process TestServer hosting the audit endpoints with stubbed
|
||||||
|
/// auth + repository. <paramref name="roles"/> is the role set the
|
||||||
|
/// substituted <see cref="RoleMapper"/> returns for the authenticated user;
|
||||||
|
/// pass an empty array to simulate a user with no audit permission.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostAsync(
|
||||||
|
string[] roles,
|
||||||
|
IReadOnlyList<AuditEvent>[]? queryPages = null,
|
||||||
|
bool ldapSucceeds = true)
|
||||||
|
{
|
||||||
|
var repo = Substitute.For<IAuditLogRepository>();
|
||||||
|
if (queryPages is { Length: > 0 })
|
||||||
|
{
|
||||||
|
var returns = queryPages
|
||||||
|
.Select(p => Task.FromResult<IReadOnlyList<AuditEvent>>(p))
|
||||||
|
.ToArray();
|
||||||
|
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(returns[0], returns.Skip(1).ToArray());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Substituted LDAP bind — AuthenticateAsync is virtual (test seam).
|
||||||
|
var ldap = Substitute.For<LdapAuthService>(
|
||||||
|
Options.Create(new SecurityOptions()),
|
||||||
|
Substitute.For<ILogger<LdapAuthService>>());
|
||||||
|
ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.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<RoleMapper>(Substitute.For<ISecurityRepository>());
|
||||||
|
roleMapper.MapGroupsToRolesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new RoleMappingResult(roles, Array.Empty<string>(), 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<AuditEvent>)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<AuditEvent>)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<AuditLogQueryFilter>(),
|
||||||
|
Arg.Is<AuditLogPaging>(p =>
|
||||||
|
p.PageSize == 2 &&
|
||||||
|
p.AfterEventId == Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002") &&
|
||||||
|
p.AfterOccurredAtUtc != null),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<AuditEvent>)new[] { SampleEvent() },
|
||||||
|
(IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>(),
|
||||||
|
});
|
||||||
|
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<AuditEvent>)Array.Empty<AuditEvent>() });
|
||||||
|
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<AuditEvent>)new[]
|
||||||
|
{
|
||||||
|
SampleEvent(Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001")),
|
||||||
|
SampleEvent(Guid.Parse("bbbbbbbb-0000-0000-0000-000000000002")),
|
||||||
|
},
|
||||||
|
(IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>(),
|
||||||
|
});
|
||||||
|
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<string, Microsoft.Extensions.Primitives.StringValues>
|
||||||
|
{
|
||||||
|
["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<string, Microsoft.Extensions.Primitives.StringValues>
|
||||||
|
{
|
||||||
|
["afterEventId"] = Guid.NewGuid().ToString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
var paging = AuditEndpoints.ParsePaging(query);
|
||||||
|
|
||||||
|
Assert.Null(paging.AfterEventId);
|
||||||
|
Assert.Null(paging.AfterOccurredAtUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,13 @@
|
|||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Akka.TestKit.Xunit2" />
|
<PackageReference Include="Akka.TestKit.Xunit2" />
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
<PackageReference Include="NSubstitute" />
|
<PackageReference Include="NSubstitute" />
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
@@ -20,5 +24,6 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../../src/ScadaLink.ManagementService/ScadaLink.ManagementService.csproj" />
|
<ProjectReference Include="../../src/ScadaLink.ManagementService/ScadaLink.ManagementService.csproj" />
|
||||||
<ProjectReference Include="../../src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
<ProjectReference Include="../../src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||||
|
<ProjectReference Include="../../src/ScadaLink.Security/ScadaLink.Security.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user