using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; using ZB.MOM.WW.OtOpcUa.Security.Ldap; namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; /// /// Production adapter that bridges OPC UA UserName /// tokens to the same the Admin UI cookie/JWT flows use, so a /// single LDAP source-of-truth governs both control-plane (Admin) and data-plane (OPC UA) /// session identities. Roles are resolved through the shared /// seam from the LDAP groups returned by the directory — /// the same seam the login endpoint uses — and the resolved set is attached to the OPC UA /// session identity for the downstream data-plane ACL evaluator. /// /// /// This authenticator is registered as a singleton, but /// (and its DbContext-backed mapping service) is scoped. A per-call DI scope is opened to /// resolve the mapper so the singleton never captures a scoped dependency. /// public sealed class LdapOpcUaUserAuthenticator( ILdapAuthService ldap, IServiceScopeFactory scopeFactory, ILogger logger) : IOpcUaUserAuthenticator { /// Authenticates an OPC UA UserName token via LDAP, resolving roles through the mapper. /// The username to authenticate. /// The password to authenticate. /// Cancellation token. public async Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) { try { var result = await ldap.AuthenticateAsync(username, password, ct).ConfigureAwait(false); if (!result.Success) { return OpcUaUserAuthResult.Deny(result.Error ?? "Invalid credentials"); } var roles = await ResolveRolesAsync(result.Groups, result.Roles, username, ct).ConfigureAwait(false); return OpcUaUserAuthResult.Allow(result.DisplayName ?? username, roles); } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogWarning(ex, "LDAP authentication threw for OPC UA user {User}", username); return OpcUaUserAuthResult.Deny("Authentication backend error"); } } /// /// Resolves the user's roles from their LDAP groups via the scoped /// , unioned with any pre-resolved roles (the DevStub /// FleetAdmin grant). A mapper fault (e.g. a DB outage) must not deny an otherwise-authenticated /// session: it falls back to the pre-resolved roles, matching the login endpoint's behaviour. /// /// The LDAP groups returned by the directory. /// Pre-resolved roles (empty on the real path; FleetAdmin under DevStub). /// The login name, for diagnostics. /// Cancellation token. private async Task> ResolveRolesAsync( IReadOnlyList groups, IReadOnlyList preResolved, string username, CancellationToken ct) { try { await using var scope = scopeFactory.CreateAsyncScope(); var mapper = scope.ServiceProvider.GetRequiredService>(); var mapping = await mapper.MapAsync(groups, ct).ConfigureAwait(false); var roles = new HashSet(preResolved, StringComparer.OrdinalIgnoreCase); foreach (var role in mapping.Roles) roles.Add(role); return [.. roles]; } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogWarning(ex, "Role-map lookup failed for OPC UA user {User}; using pre-resolved baseline roles", username); return preResolved; } } }