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); + } +}