feat(auth)!: OtOpcUa canonical control-plane roles + config-DB migration (Task 1.7)

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.
This commit is contained in:
Joseph Doherty
2026-06-02 07:30:00 -04:00
parent 8ba289f975
commit c1619d95f5
16 changed files with 2063 additions and 97 deletions
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// Verifies <see cref="LdapOpcUaUserAuthenticator"/> translates app <see cref="ILdapAuthService"/>
/// outcomes into <c>OpcUaUserAuthResult</c>, resolves roles from the directory's <em>groups</em>
/// through the shared <see cref="IGroupRoleMapper{TRole}"/> seam (Task 1.2), unions any pre-resolved
/// roles (the DevStub FleetAdmin grant) in, and turns LDAP backend exceptions into a denial rather
/// roles (the DevStub Administrator grant) in, and turns LDAP backend exceptions into a denial rather
/// than letting them escape into the SDK.
/// </summary>
public sealed class LdapOpcUaUserAuthenticatorTests
@@ -23,33 +23,33 @@ public sealed class LdapOpcUaUserAuthenticatorTests
public async Task Authenticate_LDAP_success_resolves_roles_via_mapper_from_groups()
{
// Library-style result: groups present, Roles empty (the real path). The mapper maps the
// group "configeditor" -> "ConfigEditor".
// group "configeditor" -> "Designer" (canonical, Task 1.7).
var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), null));
var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "ConfigEditor" : x).ToArray());
var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "Designer" : x).ToArray());
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);
result.Success.ShouldBeTrue();
result.DisplayName.ShouldBe("Alice");
result.Roles.ShouldBe(new[] { "ConfigEditor" });
result.Roles.ShouldBe(new[] { "Designer" });
}
/// <summary>The DevStub pre-resolved roles (FleetAdmin) survive the move to the mapper: they are
/// <summary>The DevStub pre-resolved roles (Administrator) survive the move to the mapper: they are
/// unioned with the mapper output so the dev grant still reaches the OPC UA session.</summary>
[Fact]
public async Task Authenticate_devstub_preresolved_roles_are_unioned_with_mapper()
{
// DevStub-shaped result: group "dev", pre-resolved role "FleetAdmin". Mapper maps "dev" to
// nothing, so the union is exactly {FleetAdmin}.
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "FleetAdmin" }, null));
// DevStub-shaped result: group "dev", pre-resolved role "Administrator". Mapper maps "dev" to
// nothing, so the union is exactly {Administrator}.
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "Administrator" }, null));
var mapper = new FakeMapper(_ => Array.Empty<string>());
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
result.Success.ShouldBeTrue();
result.Roles.ShouldBe(new[] { "FleetAdmin" });
result.Roles.ShouldBe(new[] { "Administrator" });
}
/// <summary>A mapper fault (e.g. DB outage) must not deny an authenticated session — it falls
@@ -57,14 +57,14 @@ public sealed class LdapOpcUaUserAuthenticatorTests
[Fact]
public async Task Authenticate_mapper_fault_falls_back_to_preresolved_roles()
{
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "FleetAdmin" }, null));
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "Administrator" }, null));
var mapper = new FakeMapper(_ => throw new InvalidOperationException("DB down"));
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
result.Success.ShouldBeTrue();
result.Roles.ShouldBe(new[] { "FleetAdmin" });
result.Roles.ShouldBe(new[] { "Administrator" });
}
/// <summary>Verifies that LDAP authentication failure returns Deny result with error text.</summary>