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,44 @@
@* Cluster-scoped counterpart of <AuthorizeView>. Renders Authorized/ChildContent only when the
signed-in user's effective role for ClusterId meets MinRole; otherwise renders NotAuthorized.
Effective role combines fleet-wide and cluster-scoped grants — see ClaimsPrincipalClusterExtensions. *@
@using System.Security.Claims
@using ZB.MOM.WW.OtOpcUa.Admin.Security
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@if (_authorized)
{
@(Authorized ?? ChildContent)
}
else
{
@NotAuthorized
}
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
/// <summary>Cluster the grant is evaluated against.</summary>
[Parameter, EditorRequired] public string ClusterId { get; set; } = string.Empty;
/// <summary>Minimum effective role required to render the authorized content.</summary>
[Parameter] public AdminRole MinRole { get; set; } = AdminRole.ConfigViewer;
/// <summary>Content shown when authorized (alias-friendly: use this or <see cref="ChildContent"/>).</summary>
[Parameter] public RenderFragment? Authorized { get; set; }
/// <summary>Default content slot — shown when authorized if <see cref="Authorized"/> is unset.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Content shown when the user lacks the required role; renders nothing when unset.</summary>
[Parameter] public RenderFragment? NotAuthorized { get; set; }
private bool _authorized;
protected override async Task OnParametersSetAsync()
{
_authorized = false;
if (AuthState is null) return;
var user = (await AuthState).User;
_authorized = user.HasClusterRole(ClusterId, MinRole);
}
}

View File

@@ -1,6 +1,7 @@
@page "/account"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@using System.Security.Claims
@using ZB.MOM.WW.OtOpcUa.Admin.Security
@using ZB.MOM.WW.OtOpcUa.Admin.Services
<h1 class="page-title">My account</h1>
@@ -14,6 +15,14 @@
.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToList();
var ldapGroups = context.User.Claims
.Where(c => c.Type == "ldap_group").Select(c => c.Value).ToList();
var clusterGrants = context.User.Claims
.Where(c => c.Type == ClusterRoleClaims.ClaimType)
.Select(c => ClusterRoleClaims.Decode(c.Value))
.Where(d => d is not null)
.Select(d => d!.Value)
.OrderBy(d => d.ClusterId, StringComparer.OrdinalIgnoreCase)
.ThenBy(d => d.Role)
.ToList();
}
<section class="card-grid rise" style="animation-delay:.02s">
@@ -25,21 +34,40 @@
<div class="metric-card">
<div class="panel-head">Admin roles</div>
@if (roles.Count == 0)
@if (roles.Count == 0 && clusterGrants.Count == 0)
{
<div class="kv"><span class="k">Roles</span><span class="v text-muted">No Admin roles mapped — sign-in would have been blocked, so if you're seeing this, the session claim is likely stale.</span></div>
}
else
{
<div class="kv">
<span class="k">Roles</span>
<span class="k">Fleet-wide roles</span>
<span class="v">
@foreach (var r in roles)
@if (roles.Count == 0)
{
<span class="chip chip-idle me-1">@r</span>
<span class="text-muted">none</span>
}
else
{
@foreach (var r in roles)
{
<span class="chip chip-idle me-1">@r</span>
}
}
</span>
</div>
@if (clusterGrants.Count > 0)
{
<div class="kv">
<span class="k">Cluster-scoped roles</span>
<span class="v">
@foreach (var g in clusterGrants)
{
<span class="chip chip-idle me-1"><span class="mono">@g.ClusterId</span>: @g.Role</span>
}
</span>
</div>
}
<div class="kv"><span class="k">LDAP groups</span><span class="v">@(ldapGroups.Count == 0 ? "(none surfaced)" : string.Join(", ", ldapGroups))</span></div>
}
</div>
@@ -50,7 +78,9 @@
<p class="px-3 pt-2 text-muted small">
Each Admin role grants a fixed capability set per <span class="mono">admin-ui.md</span> §Admin Roles.
Pages below reflect what this session can access; the route's <span class="mono">[Authorize]</span> guard
is the ground truth — this table mirrors it for readability.
is the ground truth — this table mirrors it for readability. This table covers
<em>fleet-wide</em> capabilities only — a cluster-scoped grant unlocks the same actions inside its
named cluster without satisfying these fleet-wide policies.
</p>
<div class="table-wrap">
<table class="data-table">

View File

@@ -1,7 +1,9 @@
@page "/clusters/{ClusterId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.SignalR.Client
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
@using ZB.MOM.WW.OtOpcUa.Admin.Security
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@@ -11,10 +13,24 @@
@inject GenerationService GenerationSvc
@inject NavigationManager Nav
@if (_cluster is null)
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!_canView)
{
<section class="panel notice rise" style="animation-delay:.02s">
You don't have access to cluster <span class="mono">@ClusterId</span>. A fleet-wide or
cluster-scoped Admin role grant is required — ask a fleet admin to add one on the
<a href="/role-grants">role grants</a> page.
</section>
}
else if (_cluster is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Cluster <span class="mono">@ClusterId</span> was not found.
</section>
}
else
{
@if (_liveBanner is not null)
@@ -31,7 +47,11 @@ else
@if (!_cluster.Enabled) { <span class="chip chip-idle ms-2">Disabled</span> }
</div>
<div>
@if (_currentDraft is not null)
@if (!_canEdit)
{
<span class="chip chip-idle">Read-only access</span>
}
else if (_currentDraft is not null)
{
<a href="/clusters/@ClusterId/draft/@_currentDraft.GenerationId" class="btn btn-outline-primary">
Edit current draft (gen @_currentDraft.GenerationId)
@@ -119,11 +139,15 @@ else
@code {
[Parameter] public string ClusterId { get; set; } = string.Empty;
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private ServerCluster? _cluster;
private ConfigGeneration? _currentDraft;
private ConfigGeneration? _currentPublished;
private string _tab = "overview";
private bool _busy;
private bool _loaded;
private bool _canView;
private bool _canEdit;
private HubConnection? _hub;
private string? _liveBanner;
@@ -131,6 +155,15 @@ else
protected override async Task OnInitializedAsync()
{
if (AuthState is not null)
{
var user = (await AuthState).User;
_canView = user.HasClusterRole(ClusterId, AdminRole.ConfigViewer);
_canEdit = user.HasClusterRole(ClusterId, AdminRole.ConfigEditor);
}
_loaded = true;
if (!_canView) return;
await LoadAsync();
await ConnectHubAsync();
}

View File

@@ -1,9 +1,19 @@
@page "/clusters/{ClusterId}/draft/{GenerationId:long}/diff"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject GenerationService GenerationSvc
<ClusterAuthorizeView ClusterId="@ClusterId" MinRole="AdminRole.ConfigViewer">
<NotAuthorized>
<section class="panel notice rise" style="animation-delay:.02s">
Viewing cluster <span class="mono">@ClusterId</span> requires a fleet-wide or
cluster-scoped Admin role grant.
</section>
</NotAuthorized>
<Authorized>
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="page-title mb-0">Draft diff</h1>
@@ -41,6 +51,9 @@ else
}
}
</Authorized>
</ClusterAuthorizeView>
@code {
[Parameter] public string ClusterId { get; set; } = string.Empty;
[Parameter] public long GenerationId { get; set; }

View File

@@ -1,10 +1,21 @@
@page "/clusters/{ClusterId}/draft/{GenerationId:long}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
@inject GenerationService GenerationSvc
@inject DraftValidationService ValidationSvc
@inject NavigationManager Nav
<ClusterAuthorizeView ClusterId="@ClusterId" MinRole="AdminRole.ConfigEditor">
<NotAuthorized>
<section class="panel notice rise" style="animation-delay:.02s">
Editing cluster <span class="mono">@ClusterId</span> requires the
<span class="mono">ConfigEditor</span> role for this cluster.
</section>
</NotAuthorized>
<Authorized>
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="page-title mb-0">Draft editor</h1>
@@ -13,7 +24,9 @@
<div>
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId">Back to cluster</a>
<a class="btn btn-outline-primary ms-2" href="/clusters/@ClusterId/draft/@GenerationId/diff">View diff</a>
<button class="btn btn-primary ms-2" disabled="@(_errors.Count != 0 || _busy)" @onclick="PublishAsync">Publish</button>
<ClusterAuthorizeView ClusterId="@ClusterId" MinRole="AdminRole.FleetAdmin">
<button class="btn btn-primary ms-2" disabled="@(_errors.Count != 0 || _busy)" @onclick="PublishAsync">Publish</button>
</ClusterAuthorizeView>
</div>
</div>
@@ -65,6 +78,9 @@
</div>
</div>
</Authorized>
</ClusterAuthorizeView>
@code {
[Parameter] public string ClusterId { get; set; } = string.Empty;
[Parameter] public long GenerationId { get; set; }

View File

@@ -1,13 +1,24 @@
@page "/clusters/{ClusterId}/draft/{GenerationId:long}/import-equipment"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@using Microsoft.AspNetCore.Components.Authorization
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject DriverInstanceService DriverSvc
@inject UnsService UnsSvc
@inject EquipmentImportBatchService BatchSvc
@inject NavigationManager Nav
@inject AuthenticationStateProvider AuthProvider
<ClusterAuthorizeView ClusterId="@ClusterId" MinRole="AdminRole.ConfigEditor">
<NotAuthorized>
<section class="panel notice rise" style="animation-delay:.02s">
Importing equipment into cluster <span class="mono">@ClusterId</span> requires the
<span class="mono">ConfigEditor</span> role for this cluster.
</section>
</NotAuthorized>
<Authorized>
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="page-title mb-0">Equipment CSV import</h1>
@@ -142,6 +153,9 @@
</div>
}
</Authorized>
</ClusterAuthorizeView>
@code {
[Parameter] public string ClusterId { get; set; } = string.Empty;
[Parameter] public long GenerationId { get; set; }

View File

@@ -5,6 +5,7 @@
@using ZB.MOM.WW.OtOpcUa.Admin.Security
@inject IHttpContextAccessor Http
@inject ILdapAuthService LdapAuth
@inject IAdminRoleGrantResolver GrantResolver
@inject NavigationManager Nav
<div class="login-wrap rise" style="animation-delay:.02s">
@@ -77,7 +78,10 @@
return;
}
if (result.Roles.Count == 0)
// Resolve grants from the static bootstrap dictionary + DB-backed role grants.
// result.Roles (static-only) is intentionally not consulted here.
var grants = await GrantResolver.ResolveAsync(result.Groups, CancellationToken.None);
if (grants.IsEmpty)
{
_error = "Sign-in succeeded but no Admin roles mapped for your LDAP groups. Contact your administrator.";
return;
@@ -91,8 +95,11 @@
new(ClaimTypes.Name, result.DisplayName ?? result.Username ?? _input.Username),
new(ClaimTypes.NameIdentifier, _input.Username),
};
foreach (var role in result.Roles)
foreach (var role in grants.FleetRoles)
claims.Add(new Claim(ClaimTypes.Role, role));
foreach (var clusterGrant in grants.ClusterRoles)
claims.Add(new Claim(ClusterRoleClaims.ClaimType,
ClusterRoleClaims.Encode(clusterGrant.ClusterId, clusterGrant.Role)));
foreach (var group in result.Groups)
claims.Add(new Claim("ldap_group", group));

View File

@@ -1,4 +1,5 @@
@page "/role-grants"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "CanPublish")]
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.SignalR.Client
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs