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
@@ -0,0 +1,48 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
/// <summary>
/// Default <see cref="IAdminRoleGrantResolver"/>. Merges the static <c>appsettings.json</c>
/// bootstrap dictionary with the DB-backed <c>LdapGroupRoleMapping</c> rows. See
/// <see cref="AdminRoleGrants"/> for the scope split and the decision-#150 control-plane note.
/// </summary>
public sealed class AdminRoleGrantResolver(
ILdapGroupRoleMappingService mappingService,
IOptions<LdapOptions> ldapOptions) : IAdminRoleGrantResolver
{
private readonly LdapOptions _ldap = ldapOptions.Value;
public async Task<AdminRoleGrants> ResolveAsync(
IReadOnlyList<string> ldapGroups, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(ldapGroups);
if (ldapGroups.Count == 0) return AdminRoleGrants.Empty;
// Static bootstrap dictionary — always fleet-wide, lock-out-proof fallback.
var fleet = new HashSet<string>(
RoleMapper.Map(ldapGroups, _ldap.GroupToRole), StringComparer.OrdinalIgnoreCase);
// DB-backed grants stack additively. A system-wide row folds into the fleet set;
// a cluster-scoped row becomes a (cluster, role) grant, deduped on that pair.
var mappings = await mappingService.GetByGroupsAsync(ldapGroups, cancellationToken)
.ConfigureAwait(false);
var cluster = new Dictionary<(string, string), ClusterRoleGrant>();
foreach (var m in mappings)
{
var roleName = m.Role.ToString();
if (m.IsSystemWide || string.IsNullOrEmpty(m.ClusterId))
{
fleet.Add(roleName);
}
else
{
var key = (m.ClusterId, roleName);
cluster[key] = new ClusterRoleGrant(m.ClusterId, roleName);
}
}
return new AdminRoleGrants([.. fleet], [.. cluster.Values]);
}
}
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
/// <summary>A cluster-scoped Admin-role grant — the <see cref="Role"/> binds only within <see cref="ClusterId"/>.</summary>
public sealed record ClusterRoleGrant(string ClusterId, string Role);
/// <summary>
/// The Admin roles a user holds after sign-in, split by scope. <see cref="FleetRoles"/> apply
/// across every cluster; each entry in <see cref="ClusterRoles"/> binds only within its named
/// cluster. Resolved by <see cref="IAdminRoleGrantResolver"/> from the user's LDAP groups.
/// </summary>
public sealed record AdminRoleGrants(
IReadOnlyList<string> FleetRoles,
IReadOnlyList<ClusterRoleGrant> ClusterRoles)
{
/// <summary>No grants — sign-in is blocked when a resolution yields this.</summary>
public static readonly AdminRoleGrants Empty = new([], []);
/// <summary>True when the user holds no Admin role at any scope.</summary>
public bool IsEmpty => FleetRoles.Count == 0 && ClusterRoles.Count == 0;
}
/// <summary>
/// Resolves the Admin-role grants a set of LDAP groups confers. Augments the static
/// <see cref="LdapOptions.GroupToRole"/> bootstrap dictionary (always fleet-wide) with the
/// DB-backed <c>LdapGroupRoleMapping</c> rows authored on the role-grants page — fleet-wide
/// and cluster-scoped. The static dictionary is the lock-out-proof fallback; DB grants stack
/// additively on top of it.
/// </summary>
public interface IAdminRoleGrantResolver
{
Task<AdminRoleGrants> ResolveAsync(IReadOnlyList<string> ldapGroups, CancellationToken cancellationToken);
}
@@ -0,0 +1,73 @@
using System.Security.Claims;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
/// <summary>
/// Encoding for the cluster-scoped role claim. A fleet-wide grant is a standard
/// <see cref="ClaimTypes.Role"/> claim (so the existing <c>CanEdit</c>/<c>CanPublish</c>
/// policies keep working); a cluster-scoped grant is a <see cref="ClaimType"/> claim whose
/// value packs the cluster id and role together. A cluster-scoped role deliberately does NOT
/// satisfy a fleet-wide <c>RequireRole</c> policy.
/// </summary>
public static class ClusterRoleClaims
{
/// <summary>Claim type carrying one cluster-scoped role grant.</summary>
public const string ClaimType = "cluster_role";
// Unit separator (U+001F) — cannot occur in a cluster id or an AdminRole name.
private const char Separator = '';
/// <summary>Pack a (cluster, role) pair into a claim value.</summary>
public static string Encode(string clusterId, string role) => $"{clusterId}{Separator}{role}";
/// <summary>Unpack a claim value; null when the value is malformed.</summary>
public static (string ClusterId, string Role)? Decode(string value)
{
if (string.IsNullOrEmpty(value)) return null;
var i = value.IndexOf(Separator);
return i <= 0 || i == value.Length - 1
? null
: (value[..i], value[(i + 1)..]);
}
}
/// <summary>
/// <see cref="ClaimsPrincipal"/> helpers for cluster-scoped authorization. The effective role
/// for a cluster is the highest of the user's fleet-wide roles and any cluster-scoped grant
/// for that cluster.
/// </summary>
public static class ClaimsPrincipalClusterExtensions
{
/// <summary>
/// Highest <see cref="AdminRole"/> the user holds for <paramref name="clusterId"/>,
/// combining fleet-wide and cluster-scoped grants; null when the user holds none.
/// </summary>
public static AdminRole? EffectiveClusterRole(this ClaimsPrincipal user, string clusterId)
{
ArgumentNullException.ThrowIfNull(user);
AdminRole? best = null;
foreach (var c in user.FindAll(ClaimTypes.Role))
if (Enum.TryParse<AdminRole>(c.Value, out var role))
best = Higher(best, role);
foreach (var c in user.FindAll(ClusterRoleClaims.ClaimType))
{
if (ClusterRoleClaims.Decode(c.Value) is not { } grant) continue;
if (!string.Equals(grant.ClusterId, clusterId, StringComparison.OrdinalIgnoreCase)) continue;
if (Enum.TryParse<AdminRole>(grant.Role, out var role))
best = Higher(best, role);
}
return best;
}
/// <summary>True when the user's effective role for the cluster is at least <paramref name="minRole"/>.</summary>
public static bool HasClusterRole(this ClaimsPrincipal user, string clusterId, AdminRole minRole)
=> user.EffectiveClusterRole(clusterId) is { } role && role >= minRole;
// AdminRole ordinals ascend ConfigViewer < ConfigEditor < FleetAdmin, so >= is the hierarchy.
private static AdminRole Higher(AdminRole? current, AdminRole candidate)
=> current is { } c && c >= candidate ? c : candidate;
}