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:
Joseph Doherty
2026-06-02 00:55:10 -04:00
parent 6534875476
commit 257caa7bd1
14 changed files with 495 additions and 274 deletions
@@ -1,46 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
public sealed class LdapHelperTests
{
/// <summary>Verifies that LDAP filter special characters are properly escaped.</summary>
/// <param name="input">The input string.</param>
/// <param name="expected">The expected escaped output.</param>
[Theory]
[InlineData("joe", "joe")]
[InlineData("jo*e", "jo\\2ae")]
[InlineData("jo(e", "jo\\28e")]
[InlineData("jo)e", "jo\\29e")]
[InlineData("jo\\e", "jo\\5ce")]
public void EscapeLdapFilter_escapes_special_chars(string input, string expected)
{
LdapAuthService.EscapeLdapFilter(input).ShouldBe(expected);
}
/// <summary>Verifies that the first organizational unit segment is correctly extracted from a DN.</summary>
/// <param name="dn">The distinguished name.</param>
/// <param name="expected">The expected organizational unit value.</param>
[Theory]
[InlineData("cn=joe,ou=Admins,dc=lmxopcua,dc=local", "Admins")]
[InlineData("cn=alice,dc=lmxopcua,dc=local", null)]
[InlineData("ou=Admins,dc=lmxopcua,dc=local", "Admins")]
public void ExtractOuSegment_returns_first_ou(string dn, string? expected)
{
LdapAuthService.ExtractOuSegment(dn).ShouldBe(expected);
}
/// <summary>Verifies that the first RDN value is correctly extracted from various DN formats.</summary>
/// <param name="dn">The distinguished name.</param>
/// <param name="expected">The expected RDN value.</param>
[Theory]
[InlineData("cn=Admins,dc=lmxopcua,dc=local", "Admins")]
[InlineData("cn=Admins", "Admins")]
[InlineData("Admins", "Admins")]
public void ExtractFirstRdnValue_handles_full_and_short_dns(string dn, string expected)
{
LdapAuthService.ExtractFirstRdnValue(dn).ShouldBe(expected);
}
}
@@ -0,0 +1,135 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport;
using LdapAuthFailure = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapAuthFailure;
using LibILdapAuthService = ZB.MOM.WW.Auth.Abstractions.Ldap.ILdapAuthService;
using LibLdapAuthResult = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapAuthResult;
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
/// <summary>
/// Task 1.2 — proves <see cref="OtOpcUaLdapAuthService"/> (the app's ILdapAuthService wrapper over
/// the shared <c>ZB.MOM.WW.Auth.Ldap</c> service) preserves the two app-only concerns the library
/// does not model: the <c>Enabled</c> master switch and the <c>DevStubMode</c> bypass. Both must
/// short-circuit WITHOUT delegating to the library. On the real path it adapts the library result
/// (groups, never roles) onto the app result shape with roles left for the downstream mapper.
/// </summary>
public sealed class OtOpcUaLdapAuthServiceTests
{
private static OtOpcUaLdapAuthService Build(LdapOptions options, RecordingLibService inner) =>
new(options, inner, NullLogger<OtOpcUaLdapAuthService>.Instance);
/// <summary>DevStubMode on → stub FleetAdmin success WITHOUT hitting the library.</summary>
[Fact]
public async Task DevStubMode_grants_FleetAdmin_without_calling_the_library()
{
var inner = new RecordingLibService(LibLdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
var sut = Build(new LdapOptions { Enabled = true, DevStubMode = true }, inner);
var result = await sut.AuthenticateAsync("anyone", "anything", CancellationToken.None);
result.Success.ShouldBeTrue();
result.Username.ShouldBe("anyone");
result.Groups.ShouldBe(new[] { "dev" });
result.Roles.ShouldBe(new[] { "FleetAdmin" });
inner.Called.ShouldBeFalse("DevStubMode must never reach the real directory client");
}
/// <summary>Enabled=false → denial, no library call (master switch wins over DevStubMode).</summary>
[Fact]
public async Task Disabled_denies_without_calling_the_library_even_with_devstub()
{
var inner = new RecordingLibService(LibLdapAuthResult.Success("x", "x", new[] { "g" }));
var sut = Build(new LdapOptions { Enabled = false, DevStubMode = true }, inner);
var result = await sut.AuthenticateAsync("user", "pw", CancellationToken.None);
result.Success.ShouldBeFalse();
result.Error.ShouldBe("LDAP authentication is disabled.");
inner.Called.ShouldBeFalse("a disabled provider must never touch the network");
}
/// <summary>Real path: a library success surfaces its Groups; Roles are left empty for the
/// downstream mapper (the library returns groups, not roles).</summary>
[Fact]
public async Task Real_path_success_surfaces_groups_and_leaves_roles_for_the_mapper()
{
var inner = new RecordingLibService(
LibLdapAuthResult.Success("alice", "Alice User", new[] { "ReadOnly", "Engineers" }));
var sut = Build(
new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.Ldaps },
inner);
var result = await sut.AuthenticateAsync("alice", "secret", CancellationToken.None);
inner.Called.ShouldBeTrue();
result.Success.ShouldBeTrue();
result.Username.ShouldBe("alice");
result.DisplayName.ShouldBe("Alice User");
result.Groups.ShouldBe(new[] { "ReadOnly", "Engineers" });
result.Roles.ShouldBeEmpty();
}
/// <summary>Real path: a library failure folds into a fail-closed error string.</summary>
[Fact]
public async Task Real_path_failure_folds_into_error()
{
var inner = new RecordingLibService(LibLdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
var sut = Build(
new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.Ldaps },
inner);
var result = await sut.AuthenticateAsync("alice", "wrong", CancellationToken.None);
inner.Called.ShouldBeTrue();
result.Success.ShouldBeFalse();
result.Error.ShouldBe("Invalid username or password");
}
/// <summary>Insecure transport without AllowInsecure fails closed at the auth boundary WITHOUT
/// reaching the library — preserving the bespoke service's login-time guard after UseTls→Transport.</summary>
[Fact]
public async Task Insecure_transport_without_AllowInsecure_fails_closed_without_calling_library()
{
var inner = new RecordingLibService(LibLdapAuthResult.Success("x", "x", new[] { "g" }));
var sut = Build(
new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.None, AllowInsecure = false },
inner);
var result = await sut.AuthenticateAsync("alice", "secret", CancellationToken.None);
result.Success.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error!.ShouldContain("Insecure LDAP is disabled");
inner.Called.ShouldBeFalse();
}
/// <summary>Empty username/password are rejected up front without a library call.</summary>
[Theory]
[InlineData("", "pw")]
[InlineData("user", "")]
public async Task Empty_credentials_are_rejected_without_calling_library(string user, string pw)
{
var inner = new RecordingLibService(LibLdapAuthResult.Success("x", "x", new[] { "g" }));
var sut = Build(new LdapOptions { Enabled = true, Transport = LdapTransport.Ldaps }, inner);
var result = await sut.AuthenticateAsync(user, pw, CancellationToken.None);
result.Success.ShouldBeFalse();
inner.Called.ShouldBeFalse();
}
/// <summary>Records whether the library service was invoked and returns a canned result.</summary>
private sealed class RecordingLibService(LibLdapAuthResult result) : LibILdapAuthService
{
public bool Called { get; private set; }
public Task<LibLdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
{
Called = true;
return Task.FromResult(result);
}
}
}