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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user