From ac63c2cfb2078ef0792269d607f0c28576b1bcd4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 00:32:28 -0400 Subject: [PATCH] =?UTF-8?q?ACL=20+=20role-grant=20SignalR=20invalidation?= =?UTF-8?q?=20=E2=80=94=20#196=20slice=202.=20Adds=20the=20live-push=20lay?= =?UTF-8?q?er=20so=20an=20operator=20editing=20permissions=20in=20one=20Ad?= =?UTF-8?q?min=20session=20sees=20the=20change=20in=20peer=20sessions=20wi?= =?UTF-8?q?thout=20a=20manual=20reload.=20Covers=20both=20axes=20of=20task?= =?UTF-8?q?=20#196's=20invalidation=20requirement:=20cluster-scoped=20Node?= =?UTF-8?q?Acl=20mutations=20push=20NodeAclChanged=20to=20that=20cluster's?= =?UTF-8?q?=20subscribers;=20fleet-wide=20LdapGroupRoleMapping=20CRUD=20pu?= =?UTF-8?q?shes=20RoleGrantsChanged=20to=20every=20Admin=20session=20on=20?= =?UTF-8?q?the=20fleet=20group.=20New=20AclChangeNotifier=20service=20wrap?= =?UTF-8?q?s=20IHubContext=20with=20two=20methods:=20Notif?= =?UTF-8?q?yNodeAclChangedAsync(clusterId,=20generationId)=20+=20NotifyRol?= =?UTF-8?q?eGrantsChangedAsync().=20Both=20are=20fire-and-forget=20?= =?UTF-8?q?=E2=80=94=20a=20failed=20hub=20send=20logs=20a=20warning=20+=20?= =?UTF-8?q?returns;=20the=20authoritative=20DB=20write=20already=20committ?= =?UTF-8?q?ed,=20so=20worst-case=20peers=20see=20stale=20data=20until=20th?= =?UTF-8?q?eir=20next=20poll=20(AclsTab=20has=20no=20polling=20today;=20on?= =?UTF-8?q?-parameter-set=20reload=20+=20this=20signal=20covers=20the=20pr?= =?UTF-8?q?actical=20refresh=20cases).=20Catching=20OperationCanceledExcep?= =?UTF-8?q?tion=20separately=20so=20request-teardown=20doesn't=20log=20a?= =?UTF-8?q?=20false-positive=20hub-failure.=20NodeAclService=20constructor?= =?UTF-8?q?=20gains=20an=20optional=20AclChangeNotifier=20param=20(default?= =?UTF-8?q?s=20to=20null=20so=20the=20existing=20unit=20tests=20that=20pas?= =?UTF-8?q?s=20only=20a=20DbContext=20keep=20compiling).=20GrantAsync=20+?= =?UTF-8?q?=20RevokeAsync=20both=20emit=20NodeAclChanged=20after=20the=20S?= =?UTF-8?q?aveChanges=20completes=20=E2=80=94=20the=20Revoke=20path=20uses?= =?UTF-8?q?=20the=20loaded=20row's=20ClusterId=20+=20GenerationId=20for=20?= =?UTF-8?q?accurate=20routing=20since=20the=20caller=20passes=20only=20the?= =?UTF-8?q?=20surrogate=20rowId.=20RoleGrants.razor=20consumes=20the=20not?= =?UTF-8?q?ifier=20after=20every=20Create=20+=20Delete=20+=20opens=20a=20f?= =?UTF-8?q?leet-scoped=20HubConnection=20on=20first=20render=20that=20relo?= =?UTF-8?q?ads=20the=20grant=20list=20on=20RoleGrantsChanged.=20AclsTab.ra?= =?UTF-8?q?zor=20opens=20a=20cluster-scoped=20connection=20on=20first=20re?= =?UTF-8?q?nder=20and=20reloads=20only=20when=20the=20incoming=20NodeAclCh?= =?UTF-8?q?anged=20message=20matches=20both=20the=20current=20ClusterId=20?= =?UTF-8?q?+=20GenerationId=20(so=20a=20peer=20editing=20a=20different=20d?= =?UTF-8?q?raft=20doesn't=20trigger=20spurious=20reloads).=20Both=20pages?= =?UTF-8?q?=20IAsyncDisposable=20the=20connection=20on=20navigation=20away?= =?UTF-8?q?.=20AclChangeNotifier=20is=20DI-registered=20alongside=20Permis?= =?UTF-8?q?sionProbeService.=20Two=20new=20message=20records=20in=20AclCha?= =?UTF-8?q?ngeNotifier.cs:=20NodeAclChangedMessage(ClusterId,=20Generation?= =?UTF-8?q?Id,=20ObservedAtUtc)=20+=20RoleGrantsChangedMessage(ObservedAtU?= =?UTF-8?q?tc).=20Admin.Tests=2092/92=20passing=20(unchanged=20=E2=80=94?= =?UTF-8?q?=20the=20notifier=20is=20fire-and-forget=20+=20tested=20at=20hu?= =?UTF-8?q?b=20level=20in=20existing=20FleetStatusPoller=20suite).=20Admin?= =?UTF-8?q?=20builds=200=20errors.=20One=20slice=20of=20#196=20remains:=20?= =?UTF-8?q?the=20draft-diff=20ACL=20section=20(extend=20sp=5FComputeGenera?= =?UTF-8?q?tionDiff=20to=20emit=20NodeAcl=20rows=20+=20wire=20the=20DiffVi?= =?UTF-8?q?ewer=20NodeAcl=20card=20from=20the=20empty=20placeholder=20it?= =?UTF-8?q?=20currently=20shows).=20Next=20PR.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Components/Pages/Clusters/AclsTab.razor | 28 +++++++++++ .../Components/Pages/RoleGrants.razor | 31 ++++++++++++ src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs | 1 + .../Services/AclChangeNotifier.cs | 49 +++++++++++++++++++ .../Services/NodeAclService.cs | 9 +++- 5 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/Services/AclChangeNotifier.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor index d950800..1185092 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor @@ -1,9 +1,13 @@ +@using Microsoft.AspNetCore.SignalR.Client +@using ZB.MOM.WW.OtOpcUa.Admin.Hubs @using ZB.MOM.WW.OtOpcUa.Admin.Services @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Enums @using ZB.MOM.WW.OtOpcUa.Core.Authorization @inject NodeAclService AclSvc @inject PermissionProbeService ProbeSvc +@inject NavigationManager Nav +@implements IAsyncDisposable

Access-control grants

@@ -205,6 +209,30 @@ else private static string? NullIfBlank(string s) => string.IsNullOrWhiteSpace(s) ? null : s; + private HubConnection? _hub; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || _hub is not null) return; + _hub = new HubConnectionBuilder() + .WithUrl(Nav.ToAbsoluteUri("/hubs/fleet-status")) + .WithAutomaticReconnect() + .Build(); + _hub.On("NodeAclChanged", async msg => + { + if (msg.ClusterId != ClusterId || msg.GenerationId != GenerationId) return; + _acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None); + await InvokeAsync(StateHasChanged); + }); + await _hub.StartAsync(); + await _hub.SendAsync("SubscribeCluster", ClusterId); + } + + public async ValueTask DisposeAsync() + { + if (_hub is not null) { await _hub.DisposeAsync(); _hub = null; } + } + protected override async Task OnParametersSetAsync() => _acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None); diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor index a5f9a38..e3d4fb4 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor @@ -1,10 +1,16 @@ @page "/role-grants" +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.SignalR.Client +@using ZB.MOM.WW.OtOpcUa.Admin.Hubs @using ZB.MOM.WW.OtOpcUa.Admin.Services @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Enums @using ZB.MOM.WW.OtOpcUa.Configuration.Services @inject ILdapGroupRoleMappingService RoleSvc @inject ClusterService ClusterSvc +@inject AclChangeNotifier Notifier +@inject NavigationManager Nav +@implements IAsyncDisposable

LDAP group → Admin role grants

@@ -147,6 +153,7 @@ else Notes = string.IsNullOrWhiteSpace(_notes) ? null : _notes, }; await RoleSvc.CreateAsync(row, CancellationToken.None); + await Notifier.NotifyRoleGrantsChangedAsync(CancellationToken.None); _showForm = false; await ReloadAsync(); } @@ -156,6 +163,30 @@ else private async Task DeleteAsync(Guid id) { await RoleSvc.DeleteAsync(id, CancellationToken.None); + await Notifier.NotifyRoleGrantsChangedAsync(CancellationToken.None); await ReloadAsync(); } + + private HubConnection? _hub; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || _hub is not null) return; + _hub = new HubConnectionBuilder() + .WithUrl(Nav.ToAbsoluteUri("/hubs/fleet-status")) + .WithAutomaticReconnect() + .Build(); + _hub.On("RoleGrantsChanged", async _ => + { + await ReloadAsync(); + await InvokeAsync(StateHasChanged); + }); + await _hub.StartAsync(); + await _hub.SendAsync("SubscribeFleet"); + } + + public async ValueTask DisposeAsync() + { + if (_hub is not null) { await _hub.DisposeAsync(); _hub = null; } + } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs index 52859d8..0089e24 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs @@ -45,6 +45,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/AclChangeNotifier.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/AclChangeNotifier.cs new file mode 100644 index 0000000..69e21be --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/AclChangeNotifier.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.SignalR; +using ZB.MOM.WW.OtOpcUa.Admin.Hubs; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +/// +/// Thin SignalR push helper for ACL + role-grant invalidation — slice 2 of task #196. +/// Lets the Admin services + razor pages invalidate connected peers' views without each +/// one having to know the hub wiring. Two message kinds: NodeAclChanged (cluster-scoped) +/// and RoleGrantsChanged (fleet-wide — role mappings cross cluster boundaries). +/// +/// +/// Intentionally fire-and-forget — a failed hub send doesn't rollback the DB write that +/// triggered it. Worst-case an operator sees stale data until their next poll or manual +/// refresh; better than a transient hub blip blocking the authoritative write path. +/// +public sealed class AclChangeNotifier(IHubContext fleetHub, ILogger logger) +{ + public async Task NotifyNodeAclChangedAsync(string clusterId, long generationId, CancellationToken ct) + { + try + { + var msg = new NodeAclChangedMessage(ClusterId: clusterId, GenerationId: generationId, ObservedAtUtc: DateTime.UtcNow); + await fleetHub.Clients.Group(FleetStatusHub.GroupName(clusterId)) + .SendAsync("NodeAclChanged", msg, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "NodeAclChanged push failed for cluster {ClusterId} gen {GenerationId}", clusterId, generationId); + } + } + + public async Task NotifyRoleGrantsChangedAsync(CancellationToken ct) + { + try + { + var msg = new RoleGrantsChangedMessage(ObservedAtUtc: DateTime.UtcNow); + await fleetHub.Clients.Group(FleetStatusHub.FleetGroup) + .SendAsync("RoleGrantsChanged", msg, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "RoleGrantsChanged push failed"); + } + } +} + +public sealed record NodeAclChangedMessage(string ClusterId, long GenerationId, DateTime ObservedAtUtc); +public sealed record RoleGrantsChangedMessage(DateTime ObservedAtUtc); diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/NodeAclService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/NodeAclService.cs index 7835055..b7f4859 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/NodeAclService.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/NodeAclService.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.Admin.Services; -public sealed class NodeAclService(OtOpcUaConfigDbContext db) +public sealed class NodeAclService(OtOpcUaConfigDbContext db, AclChangeNotifier? notifier = null) { public Task> ListAsync(long generationId, CancellationToken ct) => db.NodeAcls.AsNoTracking() @@ -31,6 +31,10 @@ public sealed class NodeAclService(OtOpcUaConfigDbContext db) }; db.NodeAcls.Add(acl); await db.SaveChangesAsync(ct); + + if (notifier is not null) + await notifier.NotifyNodeAclChangedAsync(clusterId, draftId, ct); + return acl; } @@ -40,5 +44,8 @@ public sealed class NodeAclService(OtOpcUaConfigDbContext db) if (row is null) return; db.NodeAcls.Remove(row); await db.SaveChangesAsync(ct); + + if (notifier is not null) + await notifier.NotifyNodeAclChangedAsync(row.ClusterId, row.GenerationId, ct); } } -- 2.49.1