181 lines
8.1 KiB
C#
181 lines
8.1 KiB
C#
using Opc.Ua;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
|
|
|
/// <summary>
|
|
/// End-to-end authz regression test for the ADR-001 Task B close-out of task #195.
|
|
/// Walks the full dispatch flow for a read against an Equipment / Identification
|
|
/// property: ScopePathIndexBuilder → NodeScopeResolver → AuthorizationGate → PermissionTrie.
|
|
/// Proves the contract the IdentificationFolderBuilder docstring promises — a user
|
|
/// without the Equipment-scope grant gets denied on the Identification sub-folder the
|
|
/// same way they would be denied on the Equipment node itself, because they share the
|
|
/// Equipment ScopeId (no new scope level for Identification per the builder's remark
|
|
/// section).
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class EquipmentIdentificationAuthzTests
|
|
{
|
|
private const string Cluster = "c-warsaw";
|
|
private const string Namespace = "ns-plc";
|
|
|
|
[Fact]
|
|
public void Authorized_Group_Read_Granted_On_Identification_Property()
|
|
{
|
|
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
|
|
var scope = resolver.Resolve("plcaddr-manufacturer");
|
|
|
|
var identity = new FakeIdentity("alice", ["cn=line-a-operators"]);
|
|
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Unauthorized_Group_Read_Denied_On_Identification_Property()
|
|
{
|
|
// The contract in task #195 + the IdentificationFolderBuilder docstring: "a user
|
|
// without the grant gets BadUserAccessDenied on both the Equipment node + its
|
|
// Identification variables." This test proves the evaluator side of that contract;
|
|
// the BadUserAccessDenied surfacing happens in the DriverNodeManager dispatch that
|
|
// already wires AuthorizationGate.IsAllowed → StatusCodes.BadUserAccessDenied.
|
|
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
|
|
var scope = resolver.Resolve("plcaddr-manufacturer");
|
|
|
|
var identity = new FakeIdentity("bob", ["cn=other-team"]);
|
|
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Equipment_Grant_Cascades_To_Its_Identification_Properties()
|
|
{
|
|
// Identification properties share their parent Equipment's ScopeId (no new scope
|
|
// level). An Equipment-scope grant must therefore read both — the Equipment's tag
|
|
// AND its Identification properties — via the same evaluator call path.
|
|
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
|
|
|
|
var tagScope = resolver.Resolve("plcaddr-temperature");
|
|
var identityScope = resolver.Resolve("plcaddr-manufacturer");
|
|
|
|
var identity = new FakeIdentity("alice", ["cn=line-a-operators"]);
|
|
gate.IsAllowed(identity, OpcUaOperation.Read, tagScope).ShouldBeTrue();
|
|
gate.IsAllowed(identity, OpcUaOperation.Read, identityScope).ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Different_Equipment_Grant_Does_Not_Leak_Across_Equipment_Boundary()
|
|
{
|
|
// Grant on oven-3; test reading a tag on press-7 (different equipment). Must deny
|
|
// so per-Equipment isolation holds at the dispatch layer — the ADR-001 Task B
|
|
// motivation for populating the full UNS path at resolve time.
|
|
var (gate, resolver) = BuildEvaluator(
|
|
equipmentGrantGroup: "cn=oven-3-operators",
|
|
equipmentIdForGrant: "eq-oven-3");
|
|
|
|
var pressScope = resolver.Resolve("plcaddr-press-7-temp"); // belongs to eq-press-7
|
|
|
|
var identity = new FakeIdentity("charlie", ["cn=oven-3-operators"]);
|
|
gate.IsAllowed(identity, OpcUaOperation.Read, pressScope).ShouldBeFalse();
|
|
}
|
|
|
|
// ----- harness -----
|
|
|
|
/// <summary>
|
|
/// Build the AuthorizationGate + NodeScopeResolver pair for a fixture with two
|
|
/// Equipment rows (oven-3 + press-7) under one UNS line, one NodeAcl grant at
|
|
/// Equipment scope for <paramref name="equipmentGrantGroup"/>, and a ScopePathIndex
|
|
/// populated via ScopePathIndexBuilder from the same Config-DB row set the
|
|
/// EquipmentNodeWalker would consume at address-space build.
|
|
/// </summary>
|
|
private static (AuthorizationGate Gate, NodeScopeResolver Resolver) BuildEvaluator(
|
|
string equipmentGrantGroup,
|
|
string equipmentIdForGrant = "eq-oven-3")
|
|
{
|
|
var (content, scopeIndex) = BuildFixture();
|
|
var resolver = new NodeScopeResolver(Cluster, scopeIndex);
|
|
|
|
var aclRow = new NodeAcl
|
|
{
|
|
NodeAclRowId = Guid.NewGuid(),
|
|
NodeAclId = Guid.NewGuid().ToString(),
|
|
GenerationId = 1,
|
|
ClusterId = Cluster,
|
|
LdapGroup = equipmentGrantGroup,
|
|
ScopeKind = NodeAclScopeKind.Equipment,
|
|
ScopeId = equipmentIdForGrant,
|
|
PermissionFlags = NodePermissions.Browse | NodePermissions.Read,
|
|
};
|
|
var paths = new Dictionary<string, NodeAclPath>
|
|
{
|
|
[equipmentIdForGrant] = new NodeAclPath(new[] { Namespace, "area-1", "line-a", equipmentIdForGrant }),
|
|
};
|
|
|
|
var cache = new PermissionTrieCache();
|
|
cache.Install(PermissionTrieBuilder.Build(Cluster, 1, [aclRow], paths));
|
|
var evaluator = new TriePermissionEvaluator(cache);
|
|
var gate = new AuthorizationGate(evaluator, strictMode: true);
|
|
|
|
_ = content;
|
|
return (gate, resolver);
|
|
}
|
|
|
|
private static (EquipmentNamespaceContent, IReadOnlyDictionary<string, NodeScope>) BuildFixture()
|
|
{
|
|
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = Cluster, Name = "warsaw", GenerationId = 1 };
|
|
var line = new UnsLine { UnsLineId = "line-a", UnsAreaId = "area-1", Name = "line-a", GenerationId = 1 };
|
|
|
|
var oven = new Equipment
|
|
{
|
|
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
|
|
EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
|
|
DriverInstanceId = "drv", UnsLineId = "line-a", Name = "oven-3",
|
|
MachineCode = "MC-oven-3", Manufacturer = "Trumpf",
|
|
};
|
|
var press = new Equipment
|
|
{
|
|
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
|
|
EquipmentId = "eq-press-7", EquipmentUuid = Guid.NewGuid(),
|
|
DriverInstanceId = "drv", UnsLineId = "line-a", Name = "press-7",
|
|
MachineCode = "MC-press-7",
|
|
};
|
|
|
|
// Two tags for oven-3, one for press-7. Use Tag.TagConfig as the driver-side full
|
|
// reference the dispatch layer passes to NodeScopeResolver.Resolve.
|
|
var tempTag = NewTag("tag-1", "Temperature", "Int32", "plcaddr-temperature", "eq-oven-3");
|
|
var mfgTag = NewTag("tag-2", "Manufacturer", "String", "plcaddr-manufacturer", "eq-oven-3");
|
|
var pressTempTag = NewTag("tag-3", "PressTemp", "Int32", "plcaddr-press-7-temp", "eq-press-7");
|
|
|
|
var content = new EquipmentNamespaceContent(
|
|
Areas: [area],
|
|
Lines: [line],
|
|
Equipment: [oven, press],
|
|
Tags: [tempTag, mfgTag, pressTempTag]);
|
|
|
|
var index = ScopePathIndexBuilder.Build(Cluster, Namespace, content);
|
|
return (content, index);
|
|
}
|
|
|
|
private static Tag NewTag(string tagId, string name, string dataType, string address, string equipmentId) => new()
|
|
{
|
|
TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = tagId,
|
|
DriverInstanceId = "drv", EquipmentId = equipmentId, Name = name,
|
|
DataType = dataType, AccessLevel = TagAccessLevel.ReadWrite, TagConfig = address,
|
|
};
|
|
|
|
private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer
|
|
{
|
|
public FakeIdentity(string name, IReadOnlyList<string> groups)
|
|
{
|
|
DisplayName = name;
|
|
LdapGroups = groups;
|
|
}
|
|
public new string DisplayName { get; }
|
|
public IReadOnlyList<string> LdapGroups { get; }
|
|
}
|
|
}
|