feat(auth): cut OtOpcUa over to ZB.MOM.WW.Auth.Ldap; preserve DevStubMode; route roles via IGroupRoleMapper (Task 1.2/1.4)
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using LibLdapAuthResult = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapAuthResult;
|
||||
using LibLdapAuthService = ZB.MOM.WW.Auth.Ldap.LdapAuthService;
|
||||
using LibILdapAuthService = ZB.MOM.WW.Auth.Abstractions.Ldap.ILdapAuthService;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
/// <summary>
|
||||
/// OtOpcUa's application <see cref="ILdapAuthService"/> — a thin wrapper around the shared
|
||||
/// <c>ZB.MOM.WW.Auth.Ldap</c> directory client that adds the two app-only concerns the shared
|
||||
/// library deliberately does not model:
|
||||
/// <list type="number">
|
||||
/// <item>the <see cref="LdapOptions.Enabled"/> master switch (disabled ⇒ deny, no bind); and</item>
|
||||
/// <item><see cref="LdapOptions.DevStubMode"/> — the dev bypass that grants a FleetAdmin
|
||||
/// session WITHOUT touching the network, so an operator can navigate the full Admin UI
|
||||
/// against a machine with no directory.</item>
|
||||
/// </list>
|
||||
/// On the real path it delegates to the library <see cref="LibLdapAuthService"/> and adapts the
|
||||
/// library result (which returns <em>groups</em>, never roles) back onto the app's
|
||||
/// <see cref="LdapAuthResult"/> shape. Role resolution itself now lives downstream in
|
||||
/// <see cref="OtOpcUaGroupRoleMapper"/> (the <c>IGroupRoleMapper<string></c> seam), which
|
||||
/// both the login endpoint and the OPC UA data-plane authenticator call with the returned
|
||||
/// <see cref="LdapAuthResult.Groups"/>. The only path that pre-populates
|
||||
/// <see cref="LdapAuthResult.Roles"/> is the DevStub success; consumers union that pre-resolved
|
||||
/// set with the mapper output so the dev FleetAdmin grant survives the move to the mapper.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fail-closed: the library never throws, and this wrapper adds no new throwing paths. The
|
||||
/// DevStub result mirrors the legacy bespoke service exactly (group <c>"dev"</c>, role
|
||||
/// <c>"FleetAdmin"</c>) so behaviour is preserved bit-for-bit on dev nodes.
|
||||
/// </remarks>
|
||||
public sealed class OtOpcUaLdapAuthService : ILdapAuthService
|
||||
{
|
||||
private readonly LdapOptions _options;
|
||||
private readonly LibILdapAuthService _inner;
|
||||
private readonly ILogger<OtOpcUaLdapAuthService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor: builds the shared-library directory client from the wire fields
|
||||
/// of the bound app <see cref="LdapOptions"/>.
|
||||
/// </summary>
|
||||
/// <param name="options">The app LDAP options (wire fields + app-only concerns).</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public OtOpcUaLdapAuthService(IOptions<LdapOptions> options, ILogger<OtOpcUaLdapAuthService> logger)
|
||||
: this(options.Value, new LibLdapAuthService(options.Value.ToLibraryOptions()), logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seam constructor: accepts an injected library <see cref="LibILdapAuthService"/> so the
|
||||
/// Enabled/DevStub/delegation logic can be unit-tested without a live directory.
|
||||
/// </summary>
|
||||
/// <param name="options">The app LDAP options.</param>
|
||||
/// <param name="inner">The shared-library directory client to delegate the real path to.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
internal OtOpcUaLdapAuthService(LdapOptions options, LibILdapAuthService inner, ILogger<OtOpcUaLdapAuthService> logger)
|
||||
{
|
||||
_options = options;
|
||||
_inner = inner;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
{
|
||||
// Enabled is the master switch and wins over DevStubMode — when LDAP auth is turned off,
|
||||
// refuse to authenticate at all (no bind, no dev-stub bypass).
|
||||
if (!_options.Enabled)
|
||||
return new(false, null, null, [], [], "LDAP authentication is disabled.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return new(false, null, null, [], [], "Username is required");
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
return new(false, null, username, [], [], "Password is required");
|
||||
|
||||
if (_options.DevStubMode)
|
||||
{
|
||||
// Dev bypass: accept any non-empty credentials and grant FleetAdmin WITHOUT a real bind.
|
||||
// Pre-populated Roles are unioned with the mapper output by both consumers, so the grant
|
||||
// survives the move to IGroupRoleMapper. Mirrors the legacy bespoke service exactly.
|
||||
_logger.LogWarning(
|
||||
"OtOpcUaLdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username);
|
||||
return new(true, username, username, ["dev"], ["FleetAdmin"], null);
|
||||
}
|
||||
|
||||
// Fail closed on a plaintext transport unless explicitly opted in. The bespoke service
|
||||
// enforced this at login (not startup), so the host still boots with an insecure-by-default
|
||||
// config and only refuses the bind here — preserved verbatim after the UseTls→Transport
|
||||
// migration (Task 1.4). The shared library's directory client does not re-check this.
|
||||
if (_options.Transport == LdapTransport.None && !_options.AllowInsecure)
|
||||
return new(false, null, username, [], [],
|
||||
"Insecure LDAP is disabled. Enable a TLS transport or set AllowInsecure for dev/test.");
|
||||
|
||||
var libResult = await _inner.AuthenticateAsync(username, password, ct).ConfigureAwait(false);
|
||||
return Adapt(libResult, username);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the shared-library <see cref="LibLdapAuthResult"/> onto the app's
|
||||
/// <see cref="LdapAuthResult"/>. The library returns groups (never roles) on success, so
|
||||
/// <see cref="LdapAuthResult.Roles"/> is left empty on the delegated path — role resolution
|
||||
/// happens downstream in the mapper. Library <see cref="LdapAuthFailure"/> codes are folded
|
||||
/// into the user-facing error strings the app already surfaces, keeping fail-closed semantics.
|
||||
/// </summary>
|
||||
/// <param name="result">The library authentication result.</param>
|
||||
/// <param name="username">The login name, used to populate the app result's Username field.</param>
|
||||
private static LdapAuthResult Adapt(LibLdapAuthResult result, string username)
|
||||
{
|
||||
if (result.Succeeded)
|
||||
return new(true, result.DisplayName, result.Username, result.Groups, [], null);
|
||||
|
||||
return new(false, null, username, [], [], FailureToError(result.Failure));
|
||||
}
|
||||
|
||||
/// <summary>Folds a structured library failure code into the app's user-facing error text.</summary>
|
||||
/// <param name="failure">The library failure code (null defensively treated as a generic error).</param>
|
||||
private static string FailureToError(LdapAuthFailure? failure) => failure switch
|
||||
{
|
||||
// The directory found no single matching user, or the password did not verify — both
|
||||
// surface as the same opaque message so a probe cannot distinguish "unknown user" from
|
||||
// "wrong password".
|
||||
LdapAuthFailure.BadCredentials => "Invalid username or password",
|
||||
LdapAuthFailure.UserNotFound => "Invalid username or password",
|
||||
LdapAuthFailure.AmbiguousUser => "Invalid username or password",
|
||||
LdapAuthFailure.GroupLookupFailed => "Invalid username or password",
|
||||
// System-side faults (directory unreachable / service-account misconfiguration) — kept as a
|
||||
// generic backend message rather than leaking the cause to the caller.
|
||||
LdapAuthFailure.ServiceAccountBindFailed => "Unexpected authentication error",
|
||||
LdapAuthFailure.Disabled => "LDAP authentication is disabled.",
|
||||
_ => "Unexpected authentication error",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user