refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,637 @@
|
||||
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 ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal-API endpoints exposing the central Audit Log (#23) over HTTP for the
|
||||
/// ScadaBridge 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() },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Registers the <c>/api/audit/query</c> and <c>/api/audit/export</c> minimal-API endpoints.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The endpoint route builder to register routes on.</param>
|
||||
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
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Handles <c>GET /api/audit/query</c>: authenticates, checks the OperationalAudit permission, and returns a keyset-paged JSON result.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP context for the current request.</param>
|
||||
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 restricted = ApplySiteScope(filter, auth.User!);
|
||||
if (restricted is null)
|
||||
{
|
||||
return Forbidden("OperationalAudit");
|
||||
}
|
||||
filter = restricted;
|
||||
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
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Handles <c>GET /api/audit/export</c>: authenticates, checks the AuditExport permission, and streams the matching rows as CSV or JSONL.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP context for the current request.</param>
|
||||
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 restricted = ApplySiteScope(filter, auth.User!);
|
||||
if (restricted is null)
|
||||
{
|
||||
return Forbidden("AuditExport");
|
||||
}
|
||||
filter = restricted;
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Applies the caller's <see cref="AuthenticatedUser.PermittedSiteIds"/> to the
|
||||
/// audit-log filter (Management-019). System-wide callers (empty PermittedSiteIds —
|
||||
/// Admin or a Deployment-style role with no scope rules attached to its mapping)
|
||||
/// see the filter unchanged. A scoped caller has any caller-supplied
|
||||
/// <c>sourceSiteId</c> intersected with their permitted set: an empty caller filter
|
||||
/// is replaced by the permitted set; an explicit out-of-scope filter (no overlap
|
||||
/// with the permitted set) returns <c>null</c> so the caller gets a 403 rather
|
||||
/// than silently empty results.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// The restricted filter, or <c>null</c> if the caller explicitly asked for
|
||||
/// sites entirely outside their permitted set.
|
||||
/// </returns>
|
||||
public static AuditLogQueryFilter? ApplySiteScope(AuditLogQueryFilter filter, AuthenticatedUser user)
|
||||
{
|
||||
// Empty PermittedSiteIds is the system-wide signal (Admin, system-wide
|
||||
// Deployment). System-wide audit roles also fall here — the design treats
|
||||
// Audit/AuditReadOnly as non-site-scoped unless an operator attaches scope
|
||||
// rules to the LDAP mapping; if they do, this helper enforces them.
|
||||
if (user.PermittedSiteIds.Length == 0)
|
||||
{
|
||||
return filter;
|
||||
}
|
||||
|
||||
var permitted = new HashSet<string>(user.PermittedSiteIds, StringComparer.Ordinal);
|
||||
|
||||
if (filter.SourceSiteIds is null || filter.SourceSiteIds.Count == 0)
|
||||
{
|
||||
// No explicit filter — restrict to permitted set.
|
||||
return filter with { SourceSiteIds = permitted.ToArray() };
|
||||
}
|
||||
|
||||
// Explicit filter — intersect.
|
||||
var intersection = filter.SourceSiteIds.Where(permitted.Contains).ToArray();
|
||||
if (intersection.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return filter with { SourceSiteIds = intersection };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Query-string parsing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. The
|
||||
/// <c>channel</c>/<c>kind</c>/<c>status</c>/<c>sourceSiteId</c> dimensions are
|
||||
/// multi-value: a repeated query param (<c>channel=A&channel=B</c>) 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This endpoint reads the source-site filter from the <c>sourceSiteId</c>
|
||||
/// query key, whereas the CentralUI export endpoint reads it as <c>site</c>.
|
||||
/// The divergence is deliberate — each endpoint matches its own CLI / UI URL
|
||||
/// builder — so do NOT "fix" the two to a single key name.
|
||||
/// </remarks>
|
||||
/// <param name="query">The HTTP query string collection to parse filter parameters from.</param>
|
||||
public static AuditLogQueryFilter ParseFilter(IQueryCollection query)
|
||||
{
|
||||
var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
|
||||
var kinds = AuditQueryParamParsers.ParseEnumList<AuditKind>(query["kind"]);
|
||||
var statuses = AuditQueryParamParsers.ParseEnumList<AuditStatus>(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;
|
||||
}
|
||||
|
||||
Guid? executionId = null;
|
||||
if (query.TryGetValue("executionId", out var execValues)
|
||||
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
|
||||
{
|
||||
executionId = parsedExec;
|
||||
}
|
||||
|
||||
Guid? parentExecutionId = null;
|
||||
if (query.TryGetValue("parentExecutionId", out var parentExecValues)
|
||||
&& Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec))
|
||||
{
|
||||
parentExecutionId = parsedParentExec;
|
||||
}
|
||||
|
||||
return new AuditLogQueryFilter(
|
||||
Channels: channels,
|
||||
Kinds: kinds,
|
||||
Statuses: statuses,
|
||||
SourceSiteIds: sourceSiteIds,
|
||||
Target: TrimToNullable(query, "target"),
|
||||
Actor: TrimToNullable(query, "actor"),
|
||||
CorrelationId: correlationId,
|
||||
ExecutionId: executionId,
|
||||
ParentExecutionId: parentExecutionId,
|
||||
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>
|
||||
/// <param name="query">The HTTP query string collection to parse paging parameters from.</param>
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// Formats a single <see cref="AuditEvent"/> as an RFC 4180 CSV row matching <see cref="CsvHeader"/>.
|
||||
/// </summary>
|
||||
/// <param name="evt">The audit event to format.</param>
|
||||
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' };
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR hub for real-time debug stream subscriptions.
|
||||
/// External consumers (CLI) connect via WebSocket, authenticate with Basic Auth,
|
||||
/// and receive streaming attribute value and alarm state changes.
|
||||
/// </summary>
|
||||
public class DebugStreamHub : Hub
|
||||
{
|
||||
private const string SessionIdKey = "DebugStreamSessionId";
|
||||
private const string RolesKey = "DebugStreamRoles";
|
||||
private const string PermittedSiteIdsKey = "DebugStreamPermittedSiteIds";
|
||||
|
||||
/// <summary>
|
||||
/// Pure site-scope authorization check for a debug-stream subscription.
|
||||
/// Returns true when the caller may subscribe to a debug stream for an instance
|
||||
/// belonging to <paramref name="instanceSiteId"/>.
|
||||
/// Admin role, or an empty <paramref name="permittedSiteIds"/> (system-wide
|
||||
/// Deployment), grants access to any site; otherwise the instance's site must be
|
||||
/// in the permitted set.
|
||||
/// </summary>
|
||||
/// <param name="roles">Roles held by the connected user.</param>
|
||||
/// <param name="permittedSiteIds">Site ids the user is scoped to; empty means system-wide.</param>
|
||||
/// <param name="instanceSiteId">Site id of the instance being subscribed to.</param>
|
||||
public static bool IsInstanceAccessAllowed(
|
||||
IReadOnlyCollection<string> roles,
|
||||
IReadOnlyCollection<string> permittedSiteIds,
|
||||
int instanceSiteId)
|
||||
{
|
||||
if (roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) return true;
|
||||
if (permittedSiteIds.Count == 0) return true; // system-wide deployment
|
||||
return permittedSiteIds.Contains(instanceSiteId.ToString());
|
||||
}
|
||||
|
||||
private readonly DebugStreamService _debugStreamService;
|
||||
private readonly IHubContext<DebugStreamHub> _hubContext;
|
||||
private readonly ILogger<DebugStreamHub> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the hub with its required services.
|
||||
/// </summary>
|
||||
/// <param name="debugStreamService">Service that manages debug stream subscriptions.</param>
|
||||
/// <param name="hubContext">SignalR hub context for pushing events to clients.</param>
|
||||
/// <param name="logger">Logger for this hub.</param>
|
||||
public DebugStreamHub(
|
||||
DebugStreamService debugStreamService,
|
||||
IHubContext<DebugStreamHub> hubContext,
|
||||
ILogger<DebugStreamHub> logger)
|
||||
{
|
||||
_debugStreamService = debugStreamService;
|
||||
_hubContext = hubContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
var httpContext = Context.GetHttpContext();
|
||||
if (httpContext == null)
|
||||
{
|
||||
_logger.LogWarning("DebugStreamHub connection rejected: no HTTP context");
|
||||
Context.Abort();
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract Basic Auth credentials
|
||||
var authHeader = httpContext.Request.Headers.Authorization.ToString();
|
||||
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("DebugStreamHub connection rejected: missing Basic Auth header");
|
||||
Context.Abort();
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
_logger.LogWarning("DebugStreamHub connection rejected: malformed Basic Auth");
|
||||
Context.Abort();
|
||||
return;
|
||||
}
|
||||
|
||||
// LDAP authentication
|
||||
var ldapAuth = httpContext.RequestServices.GetRequiredService<LdapAuthService>();
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password);
|
||||
if (!authResult.Success)
|
||||
{
|
||||
_logger.LogWarning("DebugStreamHub connection rejected: LDAP auth failed for {Username}", username);
|
||||
Context.Abort();
|
||||
return;
|
||||
}
|
||||
|
||||
// Role check — Deployment role required
|
||||
var roleMapper = httpContext.RequestServices.GetRequiredService<RoleMapper>();
|
||||
var mappingResult = await roleMapper.MapGroupsToRolesAsync(
|
||||
authResult.Groups ?? (IReadOnlyList<string>)Array.Empty<string>());
|
||||
|
||||
if (!mappingResult.Roles.Contains("Deployment"))
|
||||
{
|
||||
_logger.LogWarning("DebugStreamHub connection rejected: {Username} lacks Deployment role", username);
|
||||
Context.Abort();
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist the resolved identity on the connection so per-instance site-scope
|
||||
// enforcement can be applied to SubscribeInstance calls.
|
||||
Context.Items[RolesKey] = mappingResult.Roles.ToArray();
|
||||
Context.Items[PermittedSiteIdsKey] = mappingResult.PermittedSiteIds.ToArray();
|
||||
|
||||
_logger.LogInformation("DebugStreamHub connection established for {Username}", username);
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to a debug stream for the specified instance.
|
||||
/// Sends the initial snapshot immediately, then streams incremental changes.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">Database id of the instance to subscribe to.</param>
|
||||
public async Task SubscribeInstance(int instanceId)
|
||||
{
|
||||
// Stop any existing subscription for this connection
|
||||
await UnsubscribeInstance();
|
||||
|
||||
var connectionId = Context.ConnectionId;
|
||||
|
||||
// Per-instance site-scope enforcement: a site-scoped Deployment user must not
|
||||
// be able to stream an instance belonging to a site outside their scope.
|
||||
var httpContext = Context.GetHttpContext();
|
||||
if (httpContext == null)
|
||||
{
|
||||
_logger.LogWarning("DebugStreamHub: {ConnectionId} subscribe rejected — no HTTP context", connectionId);
|
||||
await Clients.Caller.SendAsync("OnStreamTerminated", "Authorization context unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
var roles = Context.Items.TryGetValue(RolesKey, out var rolesObj) && rolesObj is string[] r
|
||||
? r : Array.Empty<string>();
|
||||
var permittedSiteIds = Context.Items.TryGetValue(PermittedSiteIdsKey, out var sitesObj) && sitesObj is string[] s
|
||||
? s : Array.Empty<string>();
|
||||
|
||||
var instanceRepo = httpContext.RequestServices.GetRequiredService<ITemplateEngineRepository>();
|
||||
var instance = await instanceRepo.GetInstanceByIdAsync(instanceId);
|
||||
if (instance == null)
|
||||
{
|
||||
_logger.LogWarning("DebugStreamHub: {ConnectionId} subscribe rejected — instance {InstanceId} not found",
|
||||
connectionId, instanceId);
|
||||
await Clients.Caller.SendAsync("OnStreamTerminated", $"Instance {instanceId} not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsInstanceAccessAllowed(roles, permittedSiteIds, instance.SiteId))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"DebugStreamHub: {ConnectionId} subscribe to instance {InstanceId} denied — site {SiteId} outside permitted scope",
|
||||
connectionId, instanceId, instance.SiteId);
|
||||
await Clients.Caller.SendAsync("OnStreamTerminated",
|
||||
$"Access denied: instance {instanceId} belongs to a site outside your Deployment scope.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Use IHubContext for callbacks — the hub instance is transient (disposed after method returns),
|
||||
// but IHubContext is a singleton that remains valid for the lifetime of the connection.
|
||||
var hubClients = _hubContext.Clients;
|
||||
|
||||
var session = await _debugStreamService.StartStreamAsync(
|
||||
instanceId,
|
||||
onEvent: evt =>
|
||||
{
|
||||
// Fire-and-forget — if the client disconnects, SendAsync will fail silently
|
||||
_ = evt switch
|
||||
{
|
||||
AttributeValueChanged changed =>
|
||||
hubClients.Client(connectionId).SendAsync("OnAttributeChanged", changed),
|
||||
AlarmStateChanged changed =>
|
||||
hubClients.Client(connectionId).SendAsync("OnAlarmChanged", changed),
|
||||
DebugViewSnapshot snapshot =>
|
||||
hubClients.Client(connectionId).SendAsync("OnSnapshot", snapshot),
|
||||
_ => Task.CompletedTask
|
||||
};
|
||||
},
|
||||
onTerminated: () =>
|
||||
{
|
||||
_ = hubClients.Client(connectionId).SendAsync("OnStreamTerminated", "Site disconnected");
|
||||
});
|
||||
|
||||
Context.Items[SessionIdKey] = session.SessionId;
|
||||
|
||||
// Send the initial snapshot
|
||||
await Clients.Caller.SendAsync("OnSnapshot", session.InitialSnapshot);
|
||||
|
||||
_logger.LogInformation("DebugStreamHub: {ConnectionId} subscribed to instance {InstanceId}",
|
||||
connectionId, instanceId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "DebugStreamHub: Failed to subscribe {ConnectionId} to instance {InstanceId}",
|
||||
connectionId, instanceId);
|
||||
await Clients.Caller.SendAsync("OnStreamTerminated", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribes from the current debug stream.
|
||||
/// </summary>
|
||||
public Task UnsubscribeInstance()
|
||||
{
|
||||
if (Context.Items.TryGetValue(SessionIdKey, out var sessionIdObj) && sessionIdObj is string sessionId)
|
||||
{
|
||||
_debugStreamService.StopStream(sessionId);
|
||||
Context.Items.Remove(SessionIdKey);
|
||||
_logger.LogInformation("DebugStreamHub: {ConnectionId} unsubscribed", Context.ConnectionId);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
await UnsubscribeInstance();
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
using Akka.Actor;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
|
||||
public class ManagementActorHolder
|
||||
{
|
||||
/// <summary>Reference to the Management actor; null until the actor has been started.</summary>
|
||||
public IActorRef? ActorRef { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
|
||||
public static class ManagementEndpoints
|
||||
{
|
||||
private static readonly TimeSpan DefaultAskTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the ManagementActor Ask timeout from configuration
|
||||
/// (finding ManagementService-010). Falls back to <see cref="DefaultAskTimeout"/>
|
||||
/// when options are absent or the configured value is not strictly positive — a
|
||||
/// zero/negative timeout would make every management call fail immediately.
|
||||
/// </summary>
|
||||
/// <param name="options">The management service options, or <c>null</c> if not configured.</param>
|
||||
public static TimeSpan ResolveAskTimeout(ManagementServiceOptions? options)
|
||||
{
|
||||
if (options is { CommandTimeout: { Ticks: > 0 } configured })
|
||||
return configured;
|
||||
return DefaultAskTimeout;
|
||||
}
|
||||
|
||||
/// <summary>Registers the <c>POST /management</c> endpoint on the given route builder.</summary>
|
||||
/// <param name="endpoints">The route builder to add the endpoint to.</param>
|
||||
public static IEndpointRouteBuilder MapManagementAPI(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapPost("/management", (Delegate)HandleRequest);
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-request body-size ceiling for the management endpoint. ASP.NET Core's
|
||||
/// default cap is ~30 MB and would reject Transport (#24) Import calls -- a
|
||||
/// 100 MB raw bundle base64-inflates to ~140 MB plus envelope. 200 MB is
|
||||
/// comfortable without going unbounded.
|
||||
/// </summary>
|
||||
private const long MaxManagementRequestBodyBytes = 200L * 1024 * 1024;
|
||||
|
||||
private static async Task<IResult> HandleRequest(HttpContext context)
|
||||
{
|
||||
var logger = context.RequestServices.GetRequiredService<ILogger<ManagementActorHolder>>();
|
||||
|
||||
// 0. Raise the per-request body-size cap before any body is read.
|
||||
// The feature is only writable before the request body has been touched.
|
||||
var maxBodyFeature = context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();
|
||||
if (maxBodyFeature is { IsReadOnly: false })
|
||||
{
|
||||
maxBodyFeature.MaxRequestBodySize = MaxManagementRequestBodyBytes;
|
||||
}
|
||||
|
||||
// 1. Decode Basic Auth
|
||||
var authHeader = context.Request.Headers.Authorization.ToString();
|
||||
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 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 Results.Json(new { error = "Malformed Basic Auth header.", code = "AUTH_FAILED" }, statusCode: 401);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return Results.Json(new { error = "Username and password are required.", code = "AUTH_FAILED" }, statusCode: 401);
|
||||
}
|
||||
|
||||
// 2. LDAP authentication
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password);
|
||||
if (!authResult.Success)
|
||||
{
|
||||
return Results.Json(
|
||||
new { error = authResult.ErrorMessage ?? "Authentication failed.", code = "AUTH_FAILED" },
|
||||
statusCode: 401);
|
||||
}
|
||||
|
||||
// 3. Role resolution
|
||||
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 authenticatedUser = new AuthenticatedUser(
|
||||
authResult.Username!,
|
||||
authResult.DisplayName!,
|
||||
mappingResult.Roles.ToArray(),
|
||||
permittedSiteIds);
|
||||
|
||||
// 4. Parse command from request body
|
||||
string body;
|
||||
using (var reader = new StreamReader(context.Request.Body, Encoding.UTF8))
|
||||
{
|
||||
body = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
var parse = ParseCommand(body);
|
||||
if (!parse.Success)
|
||||
{
|
||||
return Results.Json(new { error = parse.ErrorMessage, code = parse.ErrorCode }, statusCode: 400);
|
||||
}
|
||||
|
||||
var command = parse.Command!;
|
||||
|
||||
// 5. Dispatch to ManagementActor
|
||||
var holder = context.RequestServices.GetRequiredService<ManagementActorHolder>();
|
||||
if (holder.ActorRef == null)
|
||||
{
|
||||
return Results.Json(new { error = "Management service not ready.", code = "SERVICE_UNAVAILABLE" }, statusCode: 503);
|
||||
}
|
||||
|
||||
var correlationId = Guid.NewGuid().ToString("N");
|
||||
var envelope = new ManagementEnvelope(authenticatedUser, command, correlationId);
|
||||
|
||||
var askTimeout = ResolveAskTimeout(
|
||||
context.RequestServices.GetService<IOptions<ManagementServiceOptions>>()?.Value);
|
||||
|
||||
object response;
|
||||
try
|
||||
{
|
||||
response = await holder.ActorRef.Ask(envelope, askTimeout);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "ManagementActor Ask timed out or failed (CorrelationId={CorrelationId})", correlationId);
|
||||
return Results.Json(new { error = "Request timed out.", code = "TIMEOUT" }, statusCode: 504);
|
||||
}
|
||||
|
||||
// 6. Map response
|
||||
return response switch
|
||||
{
|
||||
ManagementSuccess success => Results.Text(success.JsonData, "application/json", statusCode: 200),
|
||||
ManagementError error => Results.Json(new { error = error.Error, code = error.ErrorCode }, statusCode: 400),
|
||||
ManagementUnauthorized unauth => Results.Json(new { error = unauth.Message, code = "UNAUTHORIZED" }, statusCode: 403),
|
||||
_ => Results.Json(new { error = "Unexpected response.", code = "INTERNAL_ERROR" }, statusCode: 500)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of parsing a management request body into a strongly-typed command.
|
||||
/// </summary>
|
||||
public readonly record struct CommandParseResult(
|
||||
bool Success, object? Command, string? ErrorMessage, string? ErrorCode)
|
||||
{
|
||||
/// <summary>Creates a successful parse result wrapping the given command.</summary>
|
||||
/// <param name="command">The strongly-typed command object that was parsed.</param>
|
||||
public static CommandParseResult Ok(object command) => new(true, command, null, null);
|
||||
/// <summary>Creates a failed parse result with the given error message.</summary>
|
||||
/// <param name="message">Human-readable description of the parse failure.</param>
|
||||
public static CommandParseResult Fail(string message) => new(false, null, message, "BAD_REQUEST");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a management request body — a JSON object with a <c>command</c> name and an
|
||||
/// optional <c>payload</c> — into the strongly-typed command record. The parsed
|
||||
/// <see cref="JsonDocument"/> is disposed deterministically and the missing-payload
|
||||
/// case does not allocate a throwaway document (finding ManagementService-006).
|
||||
/// </summary>
|
||||
/// <param name="body">The raw JSON request body string.</param>
|
||||
public static CommandParseResult ParseCommand(string body)
|
||||
{
|
||||
using JsonDocument doc = ParseDocument(body, out var parseError);
|
||||
if (parseError != null)
|
||||
return CommandParseResult.Fail(parseError);
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("command", out var commandNameElement))
|
||||
return CommandParseResult.Fail("Missing 'command' field.");
|
||||
|
||||
var commandName = commandNameElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(commandName))
|
||||
return CommandParseResult.Fail("Empty 'command' field.");
|
||||
|
||||
var commandType = ManagementCommandRegistry.Resolve(commandName);
|
||||
if (commandType == null)
|
||||
return CommandParseResult.Fail($"Unknown command: '{commandName}'.");
|
||||
|
||||
try
|
||||
{
|
||||
// Missing payload: deserialize from the empty-object literal rather than
|
||||
// allocating (and leaking) a throwaway JsonDocument.
|
||||
var payloadJson = doc.RootElement.TryGetProperty("payload", out var p)
|
||||
? p.GetRawText()
|
||||
: "{}";
|
||||
var command = JsonSerializer.Deserialize(payloadJson, commandType,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
return CommandParseResult.Ok(command);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CommandParseResult.Fail($"Failed to deserialize payload: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonDocument ParseDocument(string body, out string? error)
|
||||
{
|
||||
try
|
||||
{
|
||||
error = null;
|
||||
return JsonDocument.Parse(body);
|
||||
}
|
||||
catch
|
||||
{
|
||||
error = "Invalid JSON body.";
|
||||
return JsonDocument.Parse("{}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
|
||||
public class ManagementServiceOptions
|
||||
{
|
||||
/// <summary>Maximum time to wait for a management command to complete before returning a timeout error; default 30 seconds.</summary>
|
||||
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the management actor holder and management service options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register into.</param>
|
||||
public static IServiceCollection AddManagementService(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<ManagementActorHolder>();
|
||||
services.AddOptions<ManagementServiceOptions>()
|
||||
.BindConfiguration("ScadaBridge:ManagementService");
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka" />
|
||||
<PackageReference Include="Akka.Cluster.Tools" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.TemplateEngine/ZB.MOM.WW.ScadaBridge.TemplateEngine.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.DeploymentManager/ZB.MOM.WW.ScadaBridge.DeploymentManager.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" />
|
||||
<ProjectReference Include="..\ZB.MOM.WW.ScadaBridge.InboundAPI\ZB.MOM.WW.ScadaBridge.InboundAPI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user