diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/ClusterAuthorizeView.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/ClusterAuthorizeView.razor new file mode 100644 index 0000000..61bff56 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/ClusterAuthorizeView.razor @@ -0,0 +1,44 @@ +@* Cluster-scoped counterpart of . 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? AuthState { get; set; } + + /// Cluster the grant is evaluated against. + [Parameter, EditorRequired] public string ClusterId { get; set; } = string.Empty; + + /// Minimum effective role required to render the authorized content. + [Parameter] public AdminRole MinRole { get; set; } = AdminRole.ConfigViewer; + + /// Content shown when authorized (alias-friendly: use this or ). + [Parameter] public RenderFragment? Authorized { get; set; } + + /// Default content slot — shown when authorized if is unset. + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// Content shown when the user lacks the required role; renders nothing when unset. + [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); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Account.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Account.razor index 30066d1..b121ca7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Account.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Account.razor @@ -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

My account

@@ -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(); }
@@ -25,21 +34,40 @@
Admin roles
- @if (roles.Count == 0) + @if (roles.Count == 0 && clusterGrants.Count == 0) {
RolesNo Admin roles mapped — sign-in would have been blocked, so if you're seeing this, the session claim is likely stale.
} else {
- Roles + Fleet-wide roles - @foreach (var r in roles) + @if (roles.Count == 0) { - @r + none + } + else + { + @foreach (var r in roles) + { + @r + } }
+ @if (clusterGrants.Count > 0) + { +
+ Cluster-scoped roles + + @foreach (var g in clusterGrants) + { + @g.ClusterId: @g.Role + } + +
+ }
LDAP groups@(ldapGroups.Count == 0 ? "(none surfaced)" : string.Join(", ", ldapGroups))
}
@@ -50,7 +78,9 @@

Each Admin role grants a fixed capability set per admin-ui.md §Admin Roles. Pages below reflect what this session can access; the route's [Authorize] guard - is the ground truth — this table mirrors it for readability. + is the ground truth — this table mirrors it for readability. This table covers + fleet-wide capabilities only — a cluster-scoped grant unlocks the same actions inside its + named cluster without satisfying these fleet-wide policies.

diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor index 6607eaf..252a6f3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor @@ -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) {

Loading…

} +else if (!_canView) +{ +
+ You don't have access to cluster @ClusterId. A fleet-wide or + cluster-scoped Admin role grant is required — ask a fleet admin to add one on the + role grants page. +
+} +else if (_cluster is null) +{ +
+ Cluster @ClusterId was not found. +
+} else { @if (_liveBanner is not null) @@ -31,7 +47,11 @@ else @if (!_cluster.Enabled) { Disabled }
- @if (_currentDraft is not null) + @if (!_canEdit) + { + Read-only access + } + else if (_currentDraft is not null) { Edit current draft (gen @_currentDraft.GenerationId) @@ -119,11 +139,15 @@ else @code { [Parameter] public string ClusterId { get; set; } = string.Empty; + [CascadingParameter] private Task? 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(); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor index 688975f..1c81532 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor @@ -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 + + +
+ Viewing cluster @ClusterId requires a fleet-wide or + cluster-scoped Admin role grant. +
+
+ +

Draft diff

@@ -41,6 +51,9 @@ else } } + + + @code { [Parameter] public string ClusterId { get; set; } = string.Empty; [Parameter] public long GenerationId { get; set; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor index 277e50e..d268331 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor @@ -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 + + +
+ Editing cluster @ClusterId requires the + ConfigEditor role for this cluster. +
+
+ +
@@ -65,6 +78,9 @@
+ + + @code { [Parameter] public string ClusterId { get; set; } = string.Empty; [Parameter] public long GenerationId { get; set; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ImportEquipment.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ImportEquipment.razor index 72d1275..11fc42a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ImportEquipment.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ImportEquipment.razor @@ -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 + + +
+ Importing equipment into cluster @ClusterId requires the + ConfigEditor role for this cluster. +
+
+ +

Equipment CSV import

@@ -142,6 +153,9 @@
} + + + @code { [Parameter] public string ClusterId { get; set; } = string.Empty; [Parameter] public long GenerationId { get; set; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor index e8c4731..56e9473 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor @@ -5,6 +5,7 @@ @using ZB.MOM.WW.OtOpcUa.Admin.Security @inject IHttpContextAccessor Http @inject ILdapAuthService LdapAuth +@inject IAdminRoleGrantResolver GrantResolver @inject NavigationManager Nav