From ecc2389ca804a0504ac97a8eafd7d634caae5439 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 00:28:17 -0400 Subject: [PATCH] =?UTF-8?q?AclsTab=20Probe-this-permission=20=E2=80=94=20f?= =?UTF-8?q?irst=20of=20three=20#196=20slices.=20New=20/clusters/{ClusterId?= =?UTF-8?q?}/draft/{GenerationId}=20ACLs-tab=20gains=20a=20probe=20card=20?= =?UTF-8?q?above=20the=20grant=20table=20so=20operators=20can=20ask=20the?= =?UTF-8?q?=20trie=20"if=20cn=3DX=20asks=20for=20permission=20Y=20on=20nod?= =?UTF-8?q?e=20Z,=20would=20it=20be=20granted,=20and=20which=20rows=20cont?= =?UTF-8?q?ributed=3F"=20without=20shell-ing=20into=20the=20DB.=20Service?= =?UTF-8?q?=20thinly=20wraps=20the=20same=20PermissionTrieBuilder=20+=20Pe?= =?UTF-8?q?rmissionTrie.CollectMatches=20call=20path=20the=20Server's=20di?= =?UTF-8?q?spatch=20layer=20uses=20at=20request=20time,=20so=20a=20probe?= =?UTF-8?q?=20answer=20is=20by=20construction=20identical=20to=20what=20th?= =?UTF-8?q?e=20live=20server=20would=20decide.=20New=20PermissionProbeServ?= =?UTF-8?q?ice.ProbeAsync(generationId,=20ldapGroup,=20NodeScope,=20requir?= =?UTF-8?q?edFlags)=20=E2=80=94=20loads=20the=20target=20generation's=20No?= =?UTF-8?q?deAcl=20rows=20filtered=20to=20the=20cluster=20(critical:=20wit?= =?UTF-8?q?hout=20the=20cluster=20filter,=20cross-cluster=20grants=20leak?= =?UTF-8?q?=20into=20the=20probe=20which=20tested=20false-positive=20in=20?= =?UTF-8?q?the=20unit=20suite),=20builds=20a=20trie,=20CollectMatches=20ag?= =?UTF-8?q?ainst=20the=20supplied=20scope=20+=20[ldapGroup],=20ORs=20the?= =?UTF-8?q?=20matched-grant=20flags=20into=20Effective,=20compares=20to=20?= =?UTF-8?q?Required.=20Returns=20PermissionProbeResult(Granted,=20Required?= =?UTF-8?q?,=20Effective,=20Matches)=20=E2=80=94=20Matches=20carries=20Lda?= =?UTF-8?q?pGroup=20+=20Scope=20+=20PermissionFlags=20per=20matched=20row?= =?UTF-8?q?=20so=20the=20UI=20can=20render=20the=20contribution=20chain.?= =?UTF-8?q?=20Zero=20side=20effects=20+=20no=20audit=20rows=20=E2=80=94=20?= =?UTF-8?q?a=20failing=20probe=20is=20a=20question,=20not=20a=20denial.=20?= =?UTF-8?q?AclsTab.razor=20gains=20the=20probe=20card=20at=20the=20top=20(?= =?UTF-8?q?before=20the=20New-grant=20form=20+=20grant=20table):=20six=20i?= =?UTF-8?q?nputs=20for=20ldap=20group=20+=20every=20NodeScope=20level=20(N?= =?UTF-8?q?amespaceId=20=E2=86=92=20UnsAreaId=20=E2=86=92=20UnsLineId=20?= =?UTF-8?q?=E2=86=92=20EquipmentId=20=E2=86=92=20TagId=20=E2=80=94=20blank?= =?UTF-8?q?=20fields=20become=20null=20so=20the=20trie=20walks=20only=20as?= =?UTF-8?q?=20deep=20as=20the=20operator=20specified),=20a=20NodePermissio?= =?UTF-8?q?ns=20dropdown=20filtered=20to=20skip=20None,=20Probe=20button,?= =?UTF-8?q?=20green=20Granted=20/=20red=20Denied=20badge=20+=20Required/Ef?= =?UTF-8?q?fective=20bitmask=20display,=20and=20(when=20matches=20exist)?= =?UTF-8?q?=20a=20small=20table=20showing=20which=20LdapGroup=20matched=20?= =?UTF-8?q?at=20which=20level=20with=20which=20flags.=20Admin=20csproj=20a?= =?UTF-8?q?dds=20ProjectReference=20to=20Core=20=E2=80=94=20the=20trie=20+?= =?UTF-8?q?=20NodeScope=20live=20there=20+=20were=20previously=20Server-on?= =?UTF-8?q?ly.=20Five=20new=20PermissionProbeServiceTests=20covering:=20cl?= =?UTF-8?q?uster-level=20row=20grants=20a=20namespace-level=20read;=20no-g?= =?UTF-8?q?roup-match=20denies=20with=20empty=20Effective;=20matching=20gr?= =?UTF-8?q?oup=20but=20insufficient=20flags=20(Browse+Read=20vs=20WriteOpe?= =?UTF-8?q?rate=20required)=20denies=20with=20correct=20Effective=20bitmas?= =?UTF-8?q?k;=20cross-cluster=20grants=20stay=20isolated=20(c2's=20WriteOp?= =?UTF-8?q?erate=20does=20NOT=20leak=20into=20c1's=20probe);=20generation?= =?UTF-8?q?=20isolation=20(gen1's=20Read-only=20does=20NOT=20let=20gen2's?= =?UTF-8?q?=20WriteOperate-requiring=20probe=20pass).=20Admin.Tests=2092/9?= =?UTF-8?q?2=20passing=20(was=2087,=20+5).=20Admin=20builds=200=20errors.?= =?UTF-8?q?=20Remaining=20#196=20slices=20=E2=80=94=20SignalR=20invalidati?= =?UTF-8?q?on=20+=20draft-diff=20ACL=20section=20=E2=80=94=20ship=20in=20f?= =?UTF-8?q?ollow-up=20PRs=20so=20the=20review=20surface=20per=20PR=20stays?= =?UTF-8?q?=20tight.?= 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 | 125 +++++++++++++++++ src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs | 1 + .../Services/PermissionProbeService.cs | 63 +++++++++ .../ZB.MOM.WW.OtOpcUa.Admin.csproj | 1 + .../PermissionProbeServiceTests.cs | 128 ++++++++++++++++++ 5 files changed, 318 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/Services/PermissionProbeService.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/PermissionProbeServiceTests.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 3d7311d..d950800 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,7 +1,9 @@ @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

Access-control grants

@@ -29,6 +31,95 @@ else } +@* Probe-this-permission — task #196 slice 1 *@ +
+
+ Probe this permission + + Ask the trie "if LDAP group X asks for permission Y on node Z, would it be granted?" — + answers the same way the live server does at request time. + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + @if (_probeResult is not null) + { + + @if (_probeResult.Granted) + { + Granted + } + else + { + Denied + } + + Required @_probeResult.Required, + Effective @_probeResult.Effective + + + } +
+ @if (_probeResult is not null && _probeResult.Matches.Count > 0) + { + + + + @foreach (var m in _probeResult.Matches) + { + + + + + + } + +
LDAP group matchedLevelFlags contributed
@m.LdapGroup@m.Scope@m.PermissionFlags
+ } + else if (_probeResult is not null) + { +
No matching grants for this (group, scope) — effective permission is None.
+ } +
+
+ @if (_showForm) {
@@ -80,6 +171,40 @@ else private string _preset = "Read"; private string? _error; + // Probe-this-permission state + private string _probeGroup = string.Empty; + private string _probeNamespaceId = string.Empty; + private string _probeUnsAreaId = string.Empty; + private string _probeUnsLineId = string.Empty; + private string _probeEquipmentId = string.Empty; + private string _probeTagId = string.Empty; + private NodePermissions _probePermission = NodePermissions.Read; + private PermissionProbeResult? _probeResult; + private bool _probing; + + private async Task RunProbeAsync() + { + if (string.IsNullOrWhiteSpace(_probeGroup)) { _probeResult = null; return; } + _probing = true; + try + { + var scope = new NodeScope + { + ClusterId = ClusterId, + NamespaceId = NullIfBlank(_probeNamespaceId), + UnsAreaId = NullIfBlank(_probeUnsAreaId), + UnsLineId = NullIfBlank(_probeUnsLineId), + EquipmentId = NullIfBlank(_probeEquipmentId), + TagId = NullIfBlank(_probeTagId), + Kind = NodeHierarchyKind.Equipment, + }; + _probeResult = await ProbeSvc.ProbeAsync(GenerationId, _probeGroup.Trim(), scope, _probePermission, CancellationToken.None); + } + finally { _probing = false; } + } + + private static string? NullIfBlank(string s) => string.IsNullOrWhiteSpace(s) ? null : s; + protected override async Task OnParametersSetAsync() => _acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None); diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs index b6f3ea3..52859d8 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs @@ -44,6 +44,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/PermissionProbeService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/PermissionProbeService.cs new file mode 100644 index 0000000..0e95a1f --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/PermissionProbeService.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +/// +/// Runs an ad-hoc permission probe against a draft or published generation's NodeAcl rows — +/// "if LDAP group X asks for permission Y on node Z, would the trie grant it, and which +/// rows contributed?" Powers the AclsTab "Probe this permission" form per the #196 sub-slice. +/// +/// +/// Thin wrapper over + — +/// the same code path the Server's dispatch layer uses at request time, so a probe result +/// is guaranteed to match what the live server would decide. The probe is read-only + has +/// no side effects; failing probes do NOT generate audit log rows. +/// +public sealed class PermissionProbeService(OtOpcUaConfigDbContext db) +{ + /// + /// Evaluate against the NodeAcl rows of + /// for a request by at + /// . Returns whether the permission would be granted + the list + /// of matching grants so the UI can show *why*. + /// + public async Task ProbeAsync( + long generationId, + string ldapGroup, + NodeScope scope, + NodePermissions required, + CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ldapGroup); + ArgumentNullException.ThrowIfNull(scope); + + var rows = await db.NodeAcls.AsNoTracking() + .Where(a => a.GenerationId == generationId && a.ClusterId == scope.ClusterId) + .ToListAsync(ct).ConfigureAwait(false); + + var trie = PermissionTrieBuilder.Build(scope.ClusterId, generationId, rows); + var matches = trie.CollectMatches(scope, [ldapGroup]); + + var effective = NodePermissions.None; + foreach (var m in matches) + effective |= m.PermissionFlags; + + var granted = (effective & required) == required; + return new PermissionProbeResult( + Granted: granted, + Required: required, + Effective: effective, + Matches: matches); + } +} + +/// Outcome of a call. +public sealed record PermissionProbeResult( + bool Granted, + NodePermissions Required, + NodePermissions Effective, + IReadOnlyList Matches); diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj b/src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj index 86778c0..1fa7aa4 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj @@ -20,6 +20,7 @@ + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/PermissionProbeServiceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/PermissionProbeServiceTests.cs new file mode 100644 index 0000000..f9e4cd6 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/PermissionProbeServiceTests.cs @@ -0,0 +1,128 @@ +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Admin.Services; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Tests; + +[Trait("Category", "Unit")] +public sealed class PermissionProbeServiceTests +{ + [Fact] + public async Task Probe_Grants_When_ClusterLevelRow_CoversRequiredFlag() + { + using var ctx = NewContext(); + SeedAcl(ctx, gen: 1, cluster: "c1", + scopeKind: NodeAclScopeKind.Cluster, scopeId: null, + group: "cn=operators", flags: NodePermissions.Browse | NodePermissions.Read); + var svc = new PermissionProbeService(ctx); + + var result = await svc.ProbeAsync( + generationId: 1, + ldapGroup: "cn=operators", + scope: new NodeScope { ClusterId = "c1", NamespaceId = "ns-1", Kind = NodeHierarchyKind.Equipment }, + required: NodePermissions.Read, + CancellationToken.None); + + result.Granted.ShouldBeTrue(); + result.Matches.Count.ShouldBe(1); + result.Matches[0].LdapGroup.ShouldBe("cn=operators"); + result.Matches[0].Scope.ShouldBe(NodeAclScopeKind.Cluster); + } + + [Fact] + public async Task Probe_Denies_When_NoGroupMatches() + { + using var ctx = NewContext(); + SeedAcl(ctx, 1, "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Read); + var svc = new PermissionProbeService(ctx); + + var result = await svc.ProbeAsync(1, "cn=random-group", + new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment }, + NodePermissions.Read, CancellationToken.None); + + result.Granted.ShouldBeFalse(); + result.Matches.ShouldBeEmpty(); + result.Effective.ShouldBe(NodePermissions.None); + } + + [Fact] + public async Task Probe_Denies_When_Effective_Missing_RequiredFlag() + { + using var ctx = NewContext(); + SeedAcl(ctx, 1, "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Browse | NodePermissions.Read); + var svc = new PermissionProbeService(ctx); + + var result = await svc.ProbeAsync(1, "cn=operators", + new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment }, + required: NodePermissions.WriteOperate, + CancellationToken.None); + + result.Granted.ShouldBeFalse(); + result.Effective.ShouldBe(NodePermissions.Browse | NodePermissions.Read); + } + + [Fact] + public async Task Probe_Ignores_Rows_From_OtherClusters() + { + using var ctx = NewContext(); + SeedAcl(ctx, 1, "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Read); + SeedAcl(ctx, 1, "c2", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.WriteOperate); + var svc = new PermissionProbeService(ctx); + + var c1Result = await svc.ProbeAsync(1, "cn=operators", + new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment }, + NodePermissions.WriteOperate, CancellationToken.None); + + c1Result.Granted.ShouldBeFalse("c2's WriteOperate grant must NOT leak into c1's probe"); + } + + [Fact] + public async Task Probe_UsesOnlyRows_From_Specified_Generation() + { + using var ctx = NewContext(); + SeedAcl(ctx, gen: 1, cluster: "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Read); + SeedAcl(ctx, gen: 2, cluster: "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.WriteOperate); + var svc = new PermissionProbeService(ctx); + + var gen1 = await svc.ProbeAsync(1, "cn=operators", + new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment }, + NodePermissions.WriteOperate, CancellationToken.None); + var gen2 = await svc.ProbeAsync(2, "cn=operators", + new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment }, + NodePermissions.WriteOperate, CancellationToken.None); + + gen1.Granted.ShouldBeFalse(); + gen2.Granted.ShouldBeTrue(); + } + + private static void SeedAcl( + OtOpcUaConfigDbContext ctx, long gen, string cluster, + NodeAclScopeKind scopeKind, string? scopeId, string group, NodePermissions flags) + { + ctx.NodeAcls.Add(new NodeAcl + { + NodeAclRowId = Guid.NewGuid(), + NodeAclId = $"acl-{Guid.NewGuid():N}"[..16], + GenerationId = gen, + ClusterId = cluster, + LdapGroup = group, + ScopeKind = scopeKind, + ScopeId = scopeId, + PermissionFlags = flags, + }); + ctx.SaveChanges(); + } + + private static OtOpcUaConfigDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new OtOpcUaConfigDbContext(opts); + } +}