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,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);
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
@if (roles.Count == 0)
|
||||
{
|
||||
<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">
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
<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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -91,6 +91,10 @@ builder.Services.Configure<LdapOptions>(
|
||||
builder.Configuration.GetSection("Authentication:Ldap"));
|
||||
builder.Services.AddScoped<ILdapAuthService, LdapAuthService>();
|
||||
|
||||
// Resolves Admin-role grants from LDAP groups at sign-in: the static appsettings bootstrap
|
||||
// dictionary augmented by the DB-backed LdapGroupRoleMapping rows (fleet-wide + cluster-scoped).
|
||||
builder.Services.AddScoped<IAdminRoleGrantResolver, AdminRoleGrantResolver>();
|
||||
|
||||
// SignalR real-time fleet status + alerts (admin-ui.md §"Real-Time Updates").
|
||||
builder.Services.AddHostedService<FleetStatusPoller>();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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