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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Security.Claims;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ClusterRoleClaimsTests
|
||||
{
|
||||
private static ClaimsPrincipal User(params Claim[] claims)
|
||||
=> new(new ClaimsIdentity(claims, authenticationType: "test"));
|
||||
|
||||
private static Claim Fleet(string role) => new(ClaimTypes.Role, role);
|
||||
|
||||
private static Claim Cluster(string clusterId, AdminRole role)
|
||||
=> new(ClusterRoleClaims.ClaimType, ClusterRoleClaims.Encode(clusterId, role.ToString()));
|
||||
|
||||
[Fact]
|
||||
public void Encode_then_decode_roundtrips()
|
||||
{
|
||||
var decoded = ClusterRoleClaims.Decode(ClusterRoleClaims.Encode("WARSAW", "FleetAdmin"));
|
||||
decoded.ShouldNotBeNull();
|
||||
decoded!.Value.ClusterId.ShouldBe("WARSAW");
|
||||
decoded.Value.Role.ShouldBe("FleetAdmin");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("nosseparator")]
|
||||
public void Decode_malformed_value_returns_null(string value)
|
||||
=> ClusterRoleClaims.Decode(value).ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void Effective_role_for_cluster_uses_fleet_wide_grant()
|
||||
{
|
||||
var user = User(Fleet("ConfigEditor"));
|
||||
user.EffectiveClusterRole("WARSAW").ShouldBe(AdminRole.ConfigEditor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Effective_role_uses_cluster_scoped_grant_for_the_named_cluster()
|
||||
{
|
||||
var user = User(Cluster("WARSAW", AdminRole.FleetAdmin));
|
||||
user.EffectiveClusterRole("WARSAW").ShouldBe(AdminRole.FleetAdmin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_scoped_grant_does_not_leak_to_another_cluster()
|
||||
{
|
||||
var user = User(Cluster("WARSAW", AdminRole.FleetAdmin));
|
||||
user.EffectiveClusterRole("BERLIN").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_match_is_case_insensitive()
|
||||
{
|
||||
var user = User(Cluster("WARSAW", AdminRole.ConfigViewer));
|
||||
user.EffectiveClusterRole("warsaw").ShouldBe(AdminRole.ConfigViewer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Effective_role_is_the_highest_of_fleet_and_cluster_grants()
|
||||
{
|
||||
var user = User(Fleet("ConfigViewer"), Cluster("WARSAW", AdminRole.FleetAdmin));
|
||||
user.EffectiveClusterRole("WARSAW").ShouldBe(AdminRole.FleetAdmin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fleet_grant_wins_when_higher_than_the_cluster_grant()
|
||||
{
|
||||
var user = User(Fleet("FleetAdmin"), Cluster("WARSAW", AdminRole.ConfigViewer));
|
||||
user.EffectiveClusterRole("WARSAW").ShouldBe(AdminRole.FleetAdmin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_grants_yields_null_effective_role()
|
||||
=> User().EffectiveClusterRole("WARSAW").ShouldBeNull();
|
||||
|
||||
[Theory]
|
||||
[InlineData(AdminRole.ConfigViewer, true)]
|
||||
[InlineData(AdminRole.ConfigEditor, true)]
|
||||
[InlineData(AdminRole.FleetAdmin, false)]
|
||||
public void Has_cluster_role_respects_the_minimum(AdminRole minRole, bool expected)
|
||||
{
|
||||
var user = User(Cluster("WARSAW", AdminRole.ConfigEditor));
|
||||
user.HasClusterRole("WARSAW", minRole).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Has_cluster_role_is_false_without_any_grant()
|
||||
=> User().HasClusterRole("WARSAW", AdminRole.ConfigViewer).ShouldBeFalse();
|
||||
}
|
||||
Reference in New Issue
Block a user