From 44d4448b37d065a844014867e8d490b4a690f194 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 21:46:21 -0400 Subject: [PATCH] =?UTF-8?q?Admin=20RoleGrants=20page=20=E2=80=94=20LDAP-gr?= =?UTF-8?q?oup=20=E2=86=92=20Admin-role=20mapping=20CRUD.=20Closes=20the?= =?UTF-8?q?=20RoleGrantsTab=20slice=20of=20task=20#144=20(Phase=206.2=20St?= =?UTF-8?q?ream=20D=20follow-up);=20the=20remaining=20three=20sub-items=20?= =?UTF-8?q?(Probe-this-permission=20on=20AclsTab,=20SignalR=20invalidation?= =?UTF-8?q?=20on=20role/ACL=20changes,=20draft-diff=20ACL=20section)=20are?= =?UTF-8?q?=20split=20into=20new=20follow-up=20task=20#196=20so=20each=20c?= =?UTF-8?q?an=20ship=20independently.=20The=20permission-trie=20evaluator?= =?UTF-8?q?=20+=20ILdapGroupRoleMappingService=20already=20exist=20from=20?= =?UTF-8?q?Phase=206.2=20Streams=20A=20+=20B=20=E2=80=94=20this=20PR=20add?= =?UTF-8?q?s=20the=20consuming=20UI=20+=20the=20DI=20registration=20that?= =?UTF-8?q?=20was=20missing.=20New=20/role-grants=20page=20at=20Components?= =?UTF-8?q?/Pages/RoleGrants.razor=20registered=20in=20MainLayout's=20side?= =?UTF-8?q?bar=20next=20to=20Certificates.=20Lists=20every=20LdapGroupRole?= =?UTF-8?q?Mapping=20row=20with=20columns=20LDAP=20group=20/=20Role=20/=20?= =?UTF-8?q?Scope=20(Fleet-wide=20or=20Cluster:X)=20/=20Created=20/=20Notes?= =?UTF-8?q?=20/=20Revoke.=20Add-grant=20form=20takes=20LDAP=20group=20DN?= =?UTF-8?q?=20+=20AdminRole=20dropdown=20(ConfigViewer,=20ConfigEditor,=20?= =?UTF-8?q?FleetAdmin)=20+=20Fleet-wide=20checkbox=20+=20Cluster=20dropdow?= =?UTF-8?q?n=20(disabled=20when=20Fleet-wide=20checked)=20+=20optional=20N?= =?UTF-8?q?otes.=20Service-layer=20invariants=20=E2=80=94=20IsSystemWide?= =?UTF-8?q?=3Dtrue=20+=20ClusterId=3Dnull,=20or=20IsSystemWide=3Dfalse=20+?= =?UTF-8?q?=20ClusterId=20populated=20=E2=80=94=20enforced=20in=20Validate?= =?UTF-8?q?Invariants;=20UI=20catches=20InvalidLdapGroupRoleMappingExcepti?= =?UTF-8?q?on=20and=20displays=20the=20message=20in=20a=20red=20alert.=20I?= =?UTF-8?q?LdapGroupRoleMappingService=20was=20present=20in=20the=20Config?= =?UTF-8?q?uration=20project=20from=20Stream=20A=20but=20never=20registere?= =?UTF-8?q?d=20in=20the=20Admin=20DI=20container=20=E2=80=94=20this=20PR?= =?UTF-8?q?=20adds=20the=20AddScoped=20registration=20so=20the=20injection?= =?UTF-8?q?=20can=20resolve.=20Control-plane/data-plane=20separation=20not?= =?UTF-8?q?e=20rendered=20in=20an=20info=20banner=20at=20the=20top=20of=20?= =?UTF-8?q?the=20page=20per=20decision=20#150=20(these=20grants=20do=20NOT?= =?UTF-8?q?=20govern=20OPC=20UA=20data-path=20authorization;=20NodeAcl=20r?= =?UTF-8?q?ows=20are=20read=20directly=20by=20the=20permission-trie=20eval?= =?UTF-8?q?uator=20without=20consulting=20role=20mappings).=20Admin=20proj?= =?UTF-8?q?ect=20builds=200=20errors;=20Admin.Tests=2072/72=20passing.=20T?= =?UTF-8?q?ask=20#196=20created=20to=20track:=20(1)=20AclsTab=20Probe-this?= =?UTF-8?q?-permission=20form=20that=20takes=20(ldap=20group,=20node=20pat?= =?UTF-8?q?h,=20permission=20flag)=20and=20runs=20it=20through=20the=20per?= =?UTF-8?q?mission=20trie,=20showing=20which=20row=20granted=20it=20+=20th?= =?UTF-8?q?e=20actual=20resolved=20grant;=20(2)=20SignalR=20invalidation?= =?UTF-8?q?=20=E2=80=94=20push=20a=20RoleGrantsChanged=20event=20when=20ro?= =?UTF-8?q?ws=20are=20created/deleted=20so=20connected=20Admin=20sessions?= =?UTF-8?q?=20reload=20without=20polling,=20ditto=20NodeAclChanged=20on=20?= =?UTF-8?q?ACL=20writes;=20(3)=20DiffViewer=20ACL=20section=20=E2=80=94=20?= =?UTF-8?q?show=20NodeAcl=20+=20LdapGroupRoleMapping=20deltas=20between=20?= =?UTF-8?q?draft=20+=20published=20alongside=20equipment/uns=20diffs.?= 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/Layout/MainLayout.razor | 1 + .../Components/Pages/RoleGrants.razor | 161 ++++++++++++++++++ src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs | 2 + 3 files changed, 164 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor index 90687dc..1007b88 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor @@ -10,6 +10,7 @@ +
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor new file mode 100644 index 0000000..a5f9a38 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor @@ -0,0 +1,161 @@ +@page "/role-grants" +@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 + +

LDAP group → Admin role grants

+ +
+ Maps LDAP groups to Admin UI roles (ConfigViewer / ConfigEditor / FleetAdmin). Control-plane + only — OPC UA data-path authorization reads NodeAcl rows directly and is + unaffected by these mappings (see decision #150). A fleet-wide grant applies across every + cluster; a cluster-scoped grant only binds within the named cluster. The same LDAP group + may hold different roles on different clusters. +
+ +
+ +
+ +@if (_rows is null) +{ +

Loading…

+} +else if (_rows.Count == 0) +{ +

No role grants defined yet. Without at least one FleetAdmin grant, + only the bootstrap admin can publish drafts.

+} +else +{ + + + + + + @foreach (var r in _rows) + { + + + + + + + + + } + +
LDAP groupRoleScopeCreatedNotes
@r.LdapGroup@r.Role@(r.IsSystemWide ? "Fleet-wide" : $"Cluster: {r.ClusterId}")@r.CreatedAtUtc.ToString("yyyy-MM-dd")@r.Notes
+} + +@if (_showForm) +{ +
+
+
New role grant
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ @if (_error is not null) {
@_error
} +
+ + +
+
+
+} + +@code { + private IReadOnlyList? _rows; + private List? _clusters; + private bool _showForm; + private string _group = string.Empty; + private AdminRole _role = AdminRole.ConfigViewer; + private bool _isSystemWide; + private string _clusterId = string.Empty; + private string? _notes; + private string? _error; + + protected override async Task OnInitializedAsync() => await ReloadAsync(); + + private async Task ReloadAsync() + { + _rows = await RoleSvc.ListAllAsync(CancellationToken.None); + _clusters = await ClusterSvc.ListAsync(CancellationToken.None); + } + + private void StartAdd() + { + _group = string.Empty; + _role = AdminRole.ConfigViewer; + _isSystemWide = false; + _clusterId = string.Empty; + _notes = null; + _error = null; + _showForm = true; + } + + private async Task SaveAsync() + { + _error = null; + try + { + var row = new LdapGroupRoleMapping + { + LdapGroup = _group.Trim(), + Role = _role, + IsSystemWide = _isSystemWide, + ClusterId = _isSystemWide ? null : (string.IsNullOrWhiteSpace(_clusterId) ? null : _clusterId), + Notes = string.IsNullOrWhiteSpace(_notes) ? null : _notes, + }; + await RoleSvc.CreateAsync(row, CancellationToken.None); + _showForm = false; + await ReloadAsync(); + } + catch (Exception ex) { _error = ex.Message; } + } + + private async Task DeleteAsync(Guid id) + { + await RoleSvc.DeleteAsync(id, CancellationToken.None); + await ReloadAsync(); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs index 1214033..fa9eaae 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs @@ -48,6 +48,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs // can be promoted to trusted via the Admin UI. Singleton: no per-request state, just