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>
@@ -313,8 +313,8 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
Success: password == "valid-password",
DisplayName: username,
Username: username,
Groups: ["FleetAdmin"],
Roles: ["FleetAdmin"],
Groups: ["Administrator"],
Roles: ["Administrator"],
Error: null));
}
}
@@ -64,10 +64,10 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
["Security:Jwt:Issuer"] = "otopcua-test",
["Security:Jwt:Audience"] = "otopcua-test",
// GroupToRole baseline bound onto LdapOptions: the production
// OtOpcUaGroupRoleMapper resolves "ConfigViewer" from the LDAP group
// OtOpcUaGroupRoleMapper resolves "Viewer" from the LDAP group
// "ReadOnly". This exercises the real mapper path — the stub no longer
// pre-populates roles, so ConfigViewer can only come from the mapper.
["Security:Ldap:GroupToRole:ReadOnly"] = "ConfigViewer",
// pre-populates roles, so Viewer can only come from the mapper.
["Security:Ldap:GroupToRole:ReadOnly"] = "Viewer",
}).Build();
services.AddOtOpcUaAuth(configuration);
services.AddSingleton<ILdapAuthService, StubLdapAuthService>();
@@ -206,13 +206,13 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
public async Task Login_merges_db_role_grant_into_claims()
{
// StubLdapAuthService returns Groups ["ReadOnly"] with empty Roles (the real production
// shape). The mapper resolves the appsettings baseline "ReadOnly" → ConfigViewer, then a
// system-wide DB row maps "ReadOnly" → FleetAdmin, so the merged set is both.
// shape). The mapper resolves the appsettings baseline "ReadOnly" → Viewer, then a
// system-wide DB row maps "ReadOnly" → Administrator, so the merged set is both.
_roleMappings.Rows.Add(new LdapGroupRoleMapping
{
Id = Guid.NewGuid(),
LdapGroup = "ReadOnly",
Role = AdminRole.FleetAdmin,
Role = AdminRole.Administrator,
IsSystemWide = true,
ClusterId = null,
});
@@ -229,8 +229,8 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
var payload = await tokenResp.Content.ReadFromJsonAsync<JsonElement>(Ct);
var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!);
roles.ShouldContain("ConfigViewer"); // appsettings baseline preserved
roles.ShouldContain("FleetAdmin"); // DB grant merged in
roles.ShouldContain("Viewer"); // appsettings baseline preserved
roles.ShouldContain("Administrator"); // DB grant merged in
}
/// <summary>Fail-closed (review I3): when the role mapper throws on the real production path
@@ -315,7 +315,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
{
Id = Guid.NewGuid(),
LdapGroup = "ReadOnly",
Role = AdminRole.FleetAdmin,
Role = AdminRole.Administrator,
IsSystemWide = true,
ClusterId = null,
});
@@ -370,7 +370,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
[Fact]
public async Task Token_payload_uses_canonical_zb_claim_keys()
{
// Arrange — the appsettings baseline maps group "ReadOnly" → role "ConfigViewer", so alice
// Arrange — the appsettings baseline maps group "ReadOnly" → role "Viewer", so alice
// (whose groups are ["ReadOnly"]) will carry at least one role in the issued JWT.
// No extra DB rows needed — the appsettings GroupToRole entry is always active.
var client = NewClient();
@@ -401,7 +401,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
// Role claim(s) must be carried under JwtTokenService.RoleClaimType (= "Role").
// This pins the role-key contract: any future rename of RoleClaimType will be caught here.
// The appsettings "ReadOnly" → "ConfigViewer" mapping guarantees alice has ≥1 role.
// The appsettings "ReadOnly" → "Viewer" mapping guarantees alice has ≥1 role.
payloadJson.TryGetProperty(JwtTokenService.RoleClaimType, out var roleEl).ShouldBeTrue(
$"JWT payload must carry at least one role under JwtTokenService.RoleClaimType " +
$"(\"{JwtTokenService.RoleClaimType}\")");
@@ -0,0 +1,155 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
/// <summary>
/// Task 1.7 — control-plane admin roles are standardized on the canonical six
/// (<c>Viewer / Operator / Engineer / Designer / Deployer / Administrator</c>). OtOpcUa
/// uses four of them: ConfigViewer→Viewer, ConfigEditor→Designer, FleetAdmin→Administrator,
/// and the appsettings-only DriverOperator→Operator. These tests pin the canonical role
/// VALUES end-to-end (mapper output claims + the real registered authorization policies) and
/// prove enforcement semantics are preserved (whoever could deploy/administer/operate before
/// still can — it is a rename, not a permission change).
/// </summary>
public sealed class CanonicalAdminRolesTests
{
// --- (a) the mapper mints the CANONICAL role claim for each native group ----------------
[Theory]
[InlineData("Viewer")] // was ConfigViewer
[InlineData("Designer")] // was ConfigEditor
[InlineData("Administrator")] // was FleetAdmin
[InlineData("Operator")] // was DriverOperator (appsettings-only string role)
public async Task Mapper_yields_canonical_role_for_native_group(string canonicalRole)
{
// appsettings GroupToRole baseline carries the canonical value verbatim.
var mapper = BuildMapper(new Dictionary<string, string> { ["the-group"] = canonicalRole });
var result = await mapper.MapAsync(["the-group"], CancellationToken.None);
result.Roles.ShouldContain(canonicalRole);
}
[Theory]
[InlineData(AdminRole.Viewer, "Viewer")]
[InlineData(AdminRole.Designer, "Designer")]
[InlineData(AdminRole.Administrator, "Administrator")]
public async Task System_wide_db_row_role_renders_as_canonical_string(AdminRole role, string expected)
{
// The DB path stringifies the enum member name (row.Role.ToString()); renaming the enum
// members is what makes the persisted/emitted string canonical.
var mapper = BuildMapper(
new Dictionary<string, string>(),
new LdapGroupRoleMapping { LdapGroup = "g", Role = role, IsSystemWide = true });
var result = await mapper.MapAsync(["g"], CancellationToken.None);
result.Roles.ShouldContain(expected);
}
// --- (b)/(c) the REAL registered authorization policies enforce on the canonical values ---
[Fact]
public async Task Deployments_role_check_authorizes_Designer_and_Administrator()
{
// Deployments.razor uses [Authorize(Roles="Administrator,Designer")] — a direct role-string
// check (not a named policy). Reproduce it via RequireRole and prove both still pass.
var policy = new AuthorizationPolicyBuilder()
.RequireRole("Administrator", "Designer")
.Build();
var authz = BuildAuthorizationService();
(await authz.AuthorizeAsync(UserInRole("Designer"), policy)).Succeeded.ShouldBeTrue();
(await authz.AuthorizeAsync(UserInRole("Administrator"), policy)).Succeeded.ShouldBeTrue();
(await authz.AuthorizeAsync(UserInRole("Viewer"), policy)).Succeeded.ShouldBeFalse();
}
[Fact]
public async Task FleetAdmin_policy_authorizes_only_Administrator()
{
var authz = BuildAuthorizationService();
// RoleGrants.razor is gated by the "FleetAdmin" named policy → RequireRole("Administrator").
(await authz.AuthorizeAsync(UserInRole("Administrator"), "FleetAdmin")).Succeeded.ShouldBeTrue();
(await authz.AuthorizeAsync(UserInRole("Designer"), "FleetAdmin")).Succeeded.ShouldBeFalse();
(await authz.AuthorizeAsync(UserInRole("Operator"), "FleetAdmin")).Succeeded.ShouldBeFalse();
(await authz.AuthorizeAsync(UserInRole("Viewer"), "FleetAdmin")).Succeeded.ShouldBeFalse();
}
[Fact]
public async Task DriverOperator_policy_authorizes_Operator_and_Administrator()
{
var authz = BuildAuthorizationService();
// DriverStatusPanel/pickers gate on the "DriverOperator" named policy →
// RequireRole("Operator","Administrator"). Operator (was DriverOperator) and Administrator
// (was FleetAdmin) both pass; a plain Viewer does not.
(await authz.AuthorizeAsync(UserInRole("Operator"), "DriverOperator")).Succeeded.ShouldBeTrue();
(await authz.AuthorizeAsync(UserInRole("Administrator"), "DriverOperator")).Succeeded.ShouldBeTrue();
(await authz.AuthorizeAsync(UserInRole("Viewer"), "DriverOperator")).Succeeded.ShouldBeFalse();
}
// --- helpers ----------------------------------------------------------------------------
private static ClaimsPrincipal UserInRole(string role)
{
// ZbClaimTypes.Role aliases ClaimTypes.Role, the default role-claim type, so RequireRole /
// IsInRole resolve against it.
var identity = new ClaimsIdentity(
[new Claim(ZbClaimTypes.Role, role)], authenticationType: "Test");
return new ClaimsPrincipal(identity);
}
private static IAuthorizationService BuildAuthorizationService()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
// Use the REAL policy registrations from AddOtOpcUaAuth; it needs the ConfigDbContext for
// DataProtection key persistence, so register an in-memory one.
services.AddDbContextFactory<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase("authz-test"));
services.AddDbContext<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase("authz-test"));
services.AddOtOpcUaAuth(new ConfigurationBuilder().Build());
return services.BuildServiceProvider().GetRequiredService<IAuthorizationService>();
}
private static OtOpcUaGroupRoleMapper BuildMapper(
IDictionary<string, string> groupToRole,
params LdapGroupRoleMapping[] dbRows)
{
var options = Microsoft.Extensions.Options.Options.Create(new LdapOptions
{
GroupToRole = new Dictionary<string, string>(groupToRole, StringComparer.OrdinalIgnoreCase),
});
return new OtOpcUaGroupRoleMapper(options, new FakeMappingService(dbRows));
}
private sealed class FakeMappingService(IReadOnlyList<LdapGroupRoleMapping> rows) : ILdapGroupRoleMappingService
{
public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
=> Task.FromResult(rows);
public Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
=> Task.FromResult(rows);
public Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public Task DeleteAsync(Guid id, CancellationToken cancellationToken)
=> throw new NotSupportedException();
}
}
@@ -31,11 +31,11 @@ public sealed class OtOpcUaGroupRoleMapperTests
[Fact]
public async Task Maps_config_group_and_drops_unmapped_group()
{
var mapper = Build(new Dictionary<string, string> { ["AdminGroup"] = "FleetAdmin" });
var mapper = Build(new Dictionary<string, string> { ["AdminGroup"] = "Administrator" });
var result = await mapper.MapAsync(["AdminGroup", "UnmappedGroup"], CancellationToken.None);
result.Roles.ShouldBe(["FleetAdmin"]);
result.Roles.ShouldBe(["Administrator"]);
result.Scope.ShouldBeNull();
}
@@ -43,13 +43,13 @@ public sealed class OtOpcUaGroupRoleMapperTests
public async Task System_wide_db_row_adds_role_on_top_of_config_baseline()
{
var mapper = Build(
new Dictionary<string, string> { ["viewers"] = "ConfigViewer" },
new LdapGroupRoleMapping { LdapGroup = "admins", Role = AdminRole.FleetAdmin, IsSystemWide = true });
new Dictionary<string, string> { ["viewers"] = "Viewer" },
new LdapGroupRoleMapping { LdapGroup = "admins", Role = AdminRole.Administrator, IsSystemWide = true });
var result = await mapper.MapAsync(["viewers", "admins"], CancellationToken.None);
result.Roles.ShouldContain("ConfigViewer");
result.Roles.ShouldContain("FleetAdmin");
result.Roles.ShouldContain("Viewer");
result.Roles.ShouldContain("Administrator");
result.Scope.ShouldBeNull();
}
@@ -61,14 +61,14 @@ public sealed class OtOpcUaGroupRoleMapperTests
new LdapGroupRoleMapping
{
LdapGroup = "site-a-editors",
Role = AdminRole.ConfigEditor,
Role = AdminRole.Designer,
IsSystemWide = false,
ClusterId = "SITE-A",
});
var result = await mapper.MapAsync(["site-a-editors"], CancellationToken.None);
result.Roles.ShouldNotContain("ConfigEditor");
result.Roles.ShouldNotContain("Designer");
result.Roles.ShouldBeEmpty();
}
@@ -77,13 +77,13 @@ public sealed class OtOpcUaGroupRoleMapperTests
{
var groupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["viewers"] = "ConfigViewer",
["editors"] = "ConfigEditor",
["viewers"] = "Viewer",
["editors"] = "Designer",
};
var dbRows = new[]
{
new LdapGroupRoleMapping { LdapGroup = "admins", Role = AdminRole.FleetAdmin, IsSystemWide = true },
new LdapGroupRoleMapping { LdapGroup = "site-a", Role = AdminRole.ConfigEditor, IsSystemWide = false, ClusterId = "SITE-A" },
new LdapGroupRoleMapping { LdapGroup = "admins", Role = AdminRole.Administrator, IsSystemWide = true },
new LdapGroupRoleMapping { LdapGroup = "site-a", Role = AdminRole.Designer, IsSystemWide = false, ClusterId = "SITE-A" },
};
var groups = new[] { "viewers", "editors", "admins", "site-a", "noise" };
@@ -21,9 +21,9 @@ 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>
/// <summary>DevStubMode on → stub Administrator success WITHOUT hitting the library.</summary>
[Fact]
public async Task DevStubMode_grants_FleetAdmin_without_calling_the_library()
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);
@@ -33,7 +33,7 @@ public sealed class OtOpcUaLdapAuthServiceTests
result.Success.ShouldBeTrue();
result.Username.ShouldBe("anyone");
result.Groups.ShouldBe(new[] { "dev" });
result.Roles.ShouldBe(new[] { "FleetAdmin" });
result.Roles.ShouldBe(new[] { "Administrator" });
inner.Called.ShouldBeFalse("DevStubMode must never reach the real directory client");
}
@@ -26,8 +26,8 @@ public sealed class RoleMapperTests
{
RoleMapper.Map(
new[] { "AdminGroup" },
new Dictionary<string, string> { ["AdminGroup"] = "FleetAdmin" })
.ShouldBe(new[] { "FleetAdmin" });
new Dictionary<string, string> { ["AdminGroup"] = "Administrator" })
.ShouldBe(new[] { "Administrator" });
}
/// <summary>
@@ -40,9 +40,9 @@ public sealed class RoleMapperTests
new[] { "admingroup" },
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["AdminGroup"] = "FleetAdmin",
["AdminGroup"] = "Administrator",
})
.ShouldBe(new[] { "FleetAdmin" });
.ShouldBe(new[] { "Administrator" });
}
/// <summary>
@@ -55,11 +55,11 @@ public sealed class RoleMapperTests
new[] { "AdminGroup", "AlsoAdmin" },
new Dictionary<string, string>
{
["AdminGroup"] = "FleetAdmin",
["AlsoAdmin"] = "FleetAdmin",
["AdminGroup"] = "Administrator",
["AlsoAdmin"] = "Administrator",
});
roles.ShouldBe(new[] { "FleetAdmin" });
roles.ShouldBe(new[] { "Administrator" });
}
[Fact]
@@ -67,16 +67,16 @@ public sealed class RoleMapperTests
{
var rows = new[]
{
new LdapGroupRoleMapping { LdapGroup = "g1", Role = AdminRole.FleetAdmin, IsSystemWide = true },
new LdapGroupRoleMapping { LdapGroup = "g2", Role = AdminRole.ConfigEditor, IsSystemWide = false, ClusterId = "SITE-A" },
new LdapGroupRoleMapping { LdapGroup = "g1", Role = AdminRole.Administrator, IsSystemWide = true },
new LdapGroupRoleMapping { LdapGroup = "g2", Role = AdminRole.Designer, IsSystemWide = false, ClusterId = "SITE-A" },
};
var result = RoleMapper.Merge(["ConfigViewer"], rows);
result.ShouldContain("ConfigViewer");
result.ShouldContain("FleetAdmin");
result.ShouldNotContain("ConfigEditor"); // cluster-scoped row ignored (global-only)
var result = RoleMapper.Merge(["Viewer"], rows);
result.ShouldContain("Viewer");
result.ShouldContain("Administrator");
result.ShouldNotContain("Designer"); // cluster-scoped row ignored (global-only)
}
[Fact]
public void Merge_with_no_db_rows_returns_baseline()
=> RoleMapper.Merge(["FleetAdmin"], []).ShouldBe(["FleetAdmin"]);
=> RoleMapper.Merge(["Administrator"], []).ShouldBe(["Administrator"]);
}