c1619d95f5
Standardize the control-plane admin role VALUES on the canonical six
(ZB.MOM.WW.Auth CanonicalRole). OtOpcUa uses four:
ConfigViewer -> Viewer
ConfigEditor -> Designer
FleetAdmin -> Administrator
DriverOperator -> Operator (appsettings-only string role)
This is a rename, not a permission change: enforcement semantics are
preserved (whoever could deploy/administer/operate before still can).
- AdminRole enum members renamed (persisted as string names via
HasConversion<string>); RoleGrants.razor dropdown default updated.
- EF DATA migration CanonicalizeAdminRoles rewrites existing
LdapGroupRoleMapping.Role rows old->new (Up) and back (Down); schema /
model snapshot byte-identical (no pending model changes).
- Enforcement role STRINGS canonicalized:
* Security policies keep their NAMES ("DriverOperator"/"FleetAdmin")
but require canonical roles: RequireRole("Operator","Administrator")
and RequireRole("Administrator").
* Deployments.razor [Authorize(Roles="Administrator,Designer")].
* DevStub now grants "Administrator"; LdapOptions/doc-comment examples
canonicalized.
- Data-plane authorization (NodePermissions/NodeAcl/IPermissionEvaluator/
TriePermissionEvaluator/UserAuthorizationState) UNTOUCHED.
- New CanonicalAdminRolesTests pins canonical claim values end-to-end and
the real registered policies; existing role-string tests updated.
136 lines
6.1 KiB
C#
136 lines
6.1 KiB
C#
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 Administrator success WITHOUT hitting the library.</summary>
|
|
[Fact]
|
|
public async Task DevStubMode_grants_Administrator_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[] { "Administrator" });
|
|
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);
|
|
}
|
|
}
|
|
}
|