feat(admin): consume LDAP role grants at sign-in, incl. cluster scoping

The role-grants page authored LdapGroupRoleMapping rows but nothing
consumed them — sign-in only read the static appsettings GroupToRole
dictionary. Wire the DB-backed grants into the auth path.

- AdminRoleGrantResolver merges the static bootstrap dictionary (always
  fleet-wide, lock-out-proof) with DB grants; system-wide rows fold into
  fleet roles, cluster-scoped rows become (cluster, role) grants.
- Login emits a ClaimTypes.Role claim per fleet role and a cluster_role
  claim per cluster-scoped grant; lock-out check spans both scopes.
- ClusterRoleClaims + ClaimsPrincipal extensions resolve the effective
  role for a cluster (highest of fleet-wide and cluster-scoped).
- ClusterAuthorizeView gates cluster pages: ClusterDetail (view +
  ConfigEditor draft actions), DraftEditor (ConfigEditor / FleetAdmin
  publish), DiffViewer (ConfigViewer), ImportEquipment (ConfigEditor).
- RoleGrants page is now FleetAdmin-only; Account surfaces fleet-wide
  and cluster-scoped grants separately.

Control-plane only — decision #150 holds, NodeAcl is untouched.

Tests: AdminRoleGrantResolverTests + ClusterRoleClaimsTests (22).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-18 03:08:39 -04:00
parent 1e04796953
commit 8adb83afee
14 changed files with 567 additions and 10 deletions

View File

@@ -0,0 +1,148 @@
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Security;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class AdminRoleGrantResolverTests
{
/// <summary>In-memory <see cref="ILdapGroupRoleMappingService"/> — only the read path is exercised.</summary>
private sealed class FakeMappingService(IReadOnlyList<LdapGroupRoleMapping> rows) : ILdapGroupRoleMappingService
{
public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
{
var set = ldapGroups.ToHashSet(StringComparer.OrdinalIgnoreCase);
return Task.FromResult<IReadOnlyList<LdapGroupRoleMapping>>(
rows.Where(r => set.Contains(r.LdapGroup)).ToList());
}
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();
}
private static AdminRoleGrantResolver Resolver(
IReadOnlyList<LdapGroupRoleMapping> rows, Dictionary<string, string>? staticMap = null)
{
var options = Options.Create(new LdapOptions
{
GroupToRole = staticMap ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
});
return new AdminRoleGrantResolver(new FakeMappingService(rows), options);
}
private static LdapGroupRoleMapping Row(string group, AdminRole role, bool systemWide, string? clusterId)
=> new()
{
Id = Guid.NewGuid(),
LdapGroup = group,
Role = role,
IsSystemWide = systemWide,
ClusterId = clusterId,
};
[Fact]
public async Task No_groups_resolves_to_empty()
{
var grants = await Resolver([]).ResolveAsync([], CancellationToken.None);
grants.IsEmpty.ShouldBeTrue();
}
[Fact]
public async Task Static_dictionary_grant_is_fleet_wide()
{
var resolver = Resolver([], new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["ReadOnly"] = "ConfigViewer",
});
var grants = await resolver.ResolveAsync(["ReadOnly"], CancellationToken.None);
grants.FleetRoles.ShouldBe(["ConfigViewer"]);
grants.ClusterRoles.ShouldBeEmpty();
}
[Fact]
public async Task System_wide_db_row_lands_in_fleet_roles()
{
var resolver = Resolver([Row("cn=admins", AdminRole.FleetAdmin, systemWide: true, clusterId: null)]);
var grants = await resolver.ResolveAsync(["cn=admins"], CancellationToken.None);
grants.FleetRoles.ShouldBe(["FleetAdmin"]);
grants.ClusterRoles.ShouldBeEmpty();
}
[Fact]
public async Task Cluster_scoped_db_row_lands_in_cluster_roles()
{
var resolver = Resolver([Row("cn=warsaw", AdminRole.ConfigEditor, systemWide: false, clusterId: "WARSAW")]);
var grants = await resolver.ResolveAsync(["cn=warsaw"], CancellationToken.None);
grants.FleetRoles.ShouldBeEmpty();
grants.ClusterRoles.Count.ShouldBe(1);
grants.ClusterRoles[0].ShouldBe(new ClusterRoleGrant("WARSAW", "ConfigEditor"));
}
[Fact]
public async Task Same_group_can_hold_different_roles_on_different_clusters()
{
var resolver = Resolver(
[
Row("cn=ops", AdminRole.FleetAdmin, systemWide: false, clusterId: "WARSAW"),
Row("cn=ops", AdminRole.ConfigViewer, systemWide: false, clusterId: "BERLIN"),
]);
var grants = await resolver.ResolveAsync(["cn=ops"], CancellationToken.None);
grants.ClusterRoles.ShouldContain(new ClusterRoleGrant("WARSAW", "FleetAdmin"));
grants.ClusterRoles.ShouldContain(new ClusterRoleGrant("BERLIN", "ConfigViewer"));
grants.ClusterRoles.Count.ShouldBe(2);
}
[Fact]
public async Task Db_grants_stack_additively_on_the_static_bootstrap()
{
var resolver = Resolver(
[Row("cn=ops", AdminRole.ConfigEditor, systemWide: false, clusterId: "WARSAW")],
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["cn=ops"] = "ConfigViewer" });
var grants = await resolver.ResolveAsync(["cn=ops"], CancellationToken.None);
grants.FleetRoles.ShouldBe(["ConfigViewer"]);
grants.ClusterRoles.ShouldBe([new ClusterRoleGrant("WARSAW", "ConfigEditor")]);
}
[Fact]
public async Task Duplicate_cluster_role_pair_is_deduped()
{
var resolver = Resolver(
[
Row("cn=a", AdminRole.ConfigEditor, systemWide: false, clusterId: "WARSAW"),
Row("cn=b", AdminRole.ConfigEditor, systemWide: false, clusterId: "WARSAW"),
]);
var grants = await resolver.ResolveAsync(["cn=a", "cn=b"], CancellationToken.None);
grants.ClusterRoles.ShouldBe([new ClusterRoleGrant("WARSAW", "ConfigEditor")]);
}
[Fact]
public async Task Groups_with_no_mapping_resolve_to_empty()
{
var grants = await Resolver([]).ResolveAsync(["cn=nobody"], CancellationToken.None);
grants.IsEmpty.ShouldBeTrue();
}
}