using System.Security.Claims; using System.Text.Json; using Akka.Actor; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.Logging; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.ManagementService; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; /// /// Default implementation — a thin facade that /// dispatches the schema-library CRUD commands to the central ManagementActor /// through the in-process (the same Ask seam the /// HTTP /management endpoint uses). The actor authorizes the command against the /// supplied (Designer-gated mutations), enforces the /// unique-name guard, validates the JSON Schema, and writes the audit row — none of /// that is re-implemented here. Mirrors / /// . /// public sealed class SchemaLibraryService : ISchemaLibraryService { /// /// camelCase + case-insensitive, matching ManagementActor.SerializeResult's /// options. is produced with those settings, /// so the deserializer must mirror them to bind every property. /// private static readonly JsonSerializerOptions ResultDeserializerOptions = new() { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; private static readonly TimeSpan AskTimeout = TimeSpan.FromSeconds(30); private readonly ManagementActorHolder _holder; private readonly AuthenticationStateProvider _auth; private readonly ILogger _logger; /// /// Initializes a new instance of the . /// /// Holder for the central ManagementActor reference. /// Authentication state provider used to project the current principal. /// Logger instance. public SchemaLibraryService( ManagementActorHolder holder, AuthenticationStateProvider auth, ILogger logger) { _holder = holder ?? throw new ArgumentNullException(nameof(holder)); _auth = auth ?? throw new ArgumentNullException(nameof(auth)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// public async Task> ListAsync(CancellationToken cancellationToken = default) { var response = await SendAsync(new ListSharedSchemasCommand(), cancellationToken); if (response is ManagementSuccess success) { var items = JsonSerializer.Deserialize>( success.JsonData, ResultDeserializerOptions); return items ?? new List(); } // Read path: log + return empty so the list table renders gracefully. _logger.LogWarning("ListSharedSchemas failed: {Response}", DescribeFailure(response)); return Array.Empty(); } /// public Task CreateAsync( string name, string? scope, string schemaJson, CancellationToken cancellationToken = default) => DispatchAsync(new CreateSharedSchemaCommand(name, scope, schemaJson), cancellationToken); /// public Task UpdateAsync( int id, string name, string? scope, string schemaJson, CancellationToken cancellationToken = default) => DispatchAsync(new UpdateSharedSchemaCommand(id, name, scope, schemaJson), cancellationToken); /// public Task DeleteAsync(int id, CancellationToken cancellationToken = default) => DispatchAsync(new DeleteSharedSchemaCommand(id), cancellationToken); /// /// Dispatches a single mutating command and maps the response (or any fault) to a /// typed . /// private async Task DispatchAsync( object command, CancellationToken cancellationToken) { var response = await SendAsync(command, cancellationToken); return response switch { ManagementSuccess => SchemaLibraryActionResult.Ok(), ManagementUnauthorized unauthorized => SchemaLibraryActionResult.Fail(unauthorized.Message), ManagementError error => SchemaLibraryActionResult.Fail(error.Error), _ => SchemaLibraryActionResult.Fail(DescribeFailure(response)), }; } /// /// Wraps in a for the /// current principal and Asks the ManagementActor. Transport faults (timeout, /// actor not yet started, cancellation→propagated) become a synthetic /// so callers handle one response shape. /// private async Task SendAsync(object command, CancellationToken cancellationToken) { var actor = _holder.ActorRef; if (actor is null) { return new ManagementError( string.Empty, "Management service is not ready.", "SERVICE_UNAVAILABLE"); } var user = await BuildAuthenticatedUserAsync(); var envelope = new ManagementEnvelope(user, command, Guid.NewGuid().ToString("N")); try { return await actor.Ask(envelope, AskTimeout, cancellationToken); } catch (OperationCanceledException) { // Caller-initiated cancel (e.g. circuit teardown) — propagate cleanly. throw; } catch (Exception ex) { _logger.LogError(ex, "ManagementActor Ask failed for {Command}", command.GetType().Name); return new ManagementError(string.Empty, ex.Message, "TRANSPORT_ERROR"); } } /// /// Projects the current Blazor to the /// the actor authorizes against — username, /// display name, role claims, and the permitted-site scope claims (mirrors the /// claim set the HTTP endpoint constructs). /// private async Task BuildAuthenticatedUserAsync() { var state = await _auth.GetAuthenticationStateAsync(); var principal = state.User; var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "unknown"; var displayName = principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value ?? username; var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToArray(); var permittedSiteIds = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToArray(); return new AuthenticatedUser(username, displayName, roles, permittedSiteIds); } /// Renders a fallback description for an unexpected/failure response. private static string DescribeFailure(object response) => response switch { ManagementUnauthorized unauthorized => unauthorized.Message, ManagementError error => error.Error, _ => "Unexpected response from the management service.", }; }