using System.Net.Sockets; using Microsoft.Extensions.Logging.Abstractions; 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.Server.Security; namespace ZB.MOM.WW.OtOpcUa.Server.Tests; /// /// Task #124 — Phase 6.2 multi-user interop matrix. Drives the live GLAuth dev directory /// (5 distinct group memberships, plus a multi-group admin) end-to-end through: /// LdapUserAuthenticator bind → resolved LDAP group list → /// against a seeded /// → expected allow/deny verdict. /// /// /// /// This is the closest a code pass can get to the manual "3-user interop matrix" Phase 6.2 /// deliverable. The remaining wire-level layer (real OPC UA client, encrypted UserName /// token through the endpoint policy) needs a security-profile knob that's tracked /// separately and stays a manual cross-client smoke (#119 / #124 manual scope). /// /// /// Closes the production gap surfaced while planning this test: RoleBasedIdentity /// did not implement , so /// lax-mode-allowed every request because it never received resolved LDAP groups. After /// this PR carries Groups alongside Roles and /// RoleBasedIdentity exposes them via the bearer interface. /// /// Skipped when GLAuth at localhost:3893 is unreachable so the suite stays /// portable. /// [Trait("Category", "LiveLdap")] public sealed class ThreeUserInteropMatrixTests { private const string GlauthHost = "localhost"; private const int GlauthPort = 3893; private const string ClusterId = "c1"; private static bool GlauthReachable() { try { using var client = new TcpClient(); var task = client.ConnectAsync(GlauthHost, GlauthPort); return task.Wait(TimeSpan.FromSeconds(1)) && client.Connected; } catch { return false; } } private static LdapOptions GlauthOptions() => new() { Enabled = true, Server = GlauthHost, Port = GlauthPort, UseTls = false, AllowInsecureLdap = true, SearchBase = "dc=lmxopcua,dc=local", ServiceAccountDn = "cn=serviceaccount,dc=lmxopcua,dc=local", ServiceAccountPassword = "serviceaccount123", DisplayNameAttribute = "cn", GroupAttribute = "memberOf", UserNameAttribute = "cn", // Identity translation — GLAuth group RDN values are the same strings as the // OPC UA roles we map to, so the GroupToRole table is straightforward. GroupToRole = new(StringComparer.OrdinalIgnoreCase) { ["ReadOnly"] = "ReadOnly", ["WriteOperate"] = WriteAuthzPolicy.RoleWriteOperate, ["WriteTune"] = WriteAuthzPolicy.RoleWriteTune, ["WriteConfigure"] = WriteAuthzPolicy.RoleWriteConfigure, ["AlarmAck"] = "AlarmAck", }, }; private static LdapUserAuthenticator NewAuthenticator() => new(GlauthOptions(), NullLogger.Instance); /// /// Production-shaped ACL ruleset — one row per LDAP group, granted at Cluster scope so /// it covers any node the matrix probes. Each group gets exactly the flags it needs; /// the matrix asserts the flag-by-flag isolation the evaluator must preserve. /// private static NodeAcl[] AclMatrix() => [ Row("ReadOnly", NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.HistoryRead), Row("WriteOperate", NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteOperate), Row("WriteTune", NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteTune), Row("WriteConfigure", NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteConfigure), Row("AlarmAck", NodePermissions.Browse | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve), ]; private static NodeAcl Row(string group, NodePermissions flags) => new() { NodeAclRowId = Guid.NewGuid(), NodeAclId = Guid.NewGuid().ToString(), GenerationId = 1, ClusterId = ClusterId, LdapGroup = group, ScopeKind = NodeAclScopeKind.Cluster, ScopeId = null, PermissionFlags = flags, }; private static NodeScope Scope() => new() { ClusterId = ClusterId, NamespaceId = "ns", UnsAreaId = "area", UnsLineId = "line", EquipmentId = "eq", TagId = "tag1", Kind = NodeHierarchyKind.Equipment, }; private static AuthorizationGate MakeStrictGate() { var cache = new PermissionTrieCache(); cache.Install(PermissionTrieBuilder.Build(ClusterId, 1, AclMatrix())); return new AuthorizationGate(new TriePermissionEvaluator(cache), strictMode: true); } private sealed class LdapBoundIdentity : UserIdentity, ILdapGroupsBearer { public LdapBoundIdentity(string userName, IReadOnlyList groups) { DisplayName = userName; LdapGroups = groups; } public new string DisplayName { get; } public IReadOnlyList LdapGroups { get; } } /// /// End-to-end: bind via LDAP, observe the resolved groups, drive every /// in the relevant subset through the strict-mode gate, and /// assert the expected verdict. One InlineData row per (user, operation) pair so failures /// report the precise cell that broke. /// [Theory] // readonly — read-side only [InlineData("readonly", "readonly123", OpcUaOperation.Browse, true)] [InlineData("readonly", "readonly123", OpcUaOperation.Read, true)] [InlineData("readonly", "readonly123", OpcUaOperation.HistoryRead, true)] [InlineData("readonly", "readonly123", OpcUaOperation.WriteOperate, false)] [InlineData("readonly", "readonly123", OpcUaOperation.WriteTune, false)] [InlineData("readonly", "readonly123", OpcUaOperation.WriteConfigure, false)] [InlineData("readonly", "readonly123", OpcUaOperation.AlarmAcknowledge, false)] // writeop — Operate writes only, no escalation to Tune/Configure/Alarm [InlineData("writeop", "writeop123", OpcUaOperation.Read, true)] [InlineData("writeop", "writeop123", OpcUaOperation.WriteOperate, true)] [InlineData("writeop", "writeop123", OpcUaOperation.WriteTune, false)] [InlineData("writeop", "writeop123", OpcUaOperation.WriteConfigure, false)] [InlineData("writeop", "writeop123", OpcUaOperation.AlarmAcknowledge, false)] // writetune — Tune writes only [InlineData("writetune", "writetune123", OpcUaOperation.Read, true)] [InlineData("writetune", "writetune123", OpcUaOperation.WriteOperate, false)] [InlineData("writetune", "writetune123", OpcUaOperation.WriteTune, true)] [InlineData("writetune", "writetune123", OpcUaOperation.WriteConfigure, false)] // writeconfig — Configure writes only [InlineData("writeconfig", "writeconfig123", OpcUaOperation.Read, true)] [InlineData("writeconfig", "writeconfig123", OpcUaOperation.WriteOperate, false)] [InlineData("writeconfig", "writeconfig123", OpcUaOperation.WriteTune, false)] [InlineData("writeconfig", "writeconfig123", OpcUaOperation.WriteConfigure, true)] // alarmack — alarm-only; deliberately has no Read grant. Verifies flag isolation. [InlineData("alarmack", "alarmack123", OpcUaOperation.Browse, true)] [InlineData("alarmack", "alarmack123", OpcUaOperation.Read, false)] [InlineData("alarmack", "alarmack123", OpcUaOperation.WriteOperate, false)] [InlineData("alarmack", "alarmack123", OpcUaOperation.AlarmAcknowledge, true)] [InlineData("alarmack", "alarmack123", OpcUaOperation.AlarmConfirm, true)] [InlineData("alarmack", "alarmack123", OpcUaOperation.AlarmShelve, true)] // admin — member of every group; OR-ing across groups means everything is allowed. [InlineData("admin", "admin123", OpcUaOperation.Read, true)] [InlineData("admin", "admin123", OpcUaOperation.WriteOperate, true)] [InlineData("admin", "admin123", OpcUaOperation.WriteTune, true)] [InlineData("admin", "admin123", OpcUaOperation.WriteConfigure, true)] [InlineData("admin", "admin123", OpcUaOperation.AlarmAcknowledge, true)] public async Task Matrix(string username, string password, OpcUaOperation op, bool expectAllow) { if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test."); var auth = await NewAuthenticator().AuthenticateAsync(username, password, TestContext.Current.CancellationToken); auth.Success.ShouldBeTrue($"LDAP bind for {username} failed: {auth.Error}"); auth.Groups.ShouldNotBeEmpty($"{username} resolved zero LDAP groups — the bind succeeded but the directory query returned nothing"); var identity = new LdapBoundIdentity(username, auth.Groups); var gate = MakeStrictGate(); var allowed = gate.IsAllowed(identity, op, Scope()); allowed.ShouldBe(expectAllow, $"user={username} op={op} groups=[{string.Join(",", auth.Groups)}] expected={expectAllow}"); } [Fact] public async Task Admin_Resolves_All_Five_Groups_From_LDAP() { // Sanity check separate from the matrix: the admin user must surface every group it // belongs to via the new UserAuthResult.Groups channel — the matrix above relies on // exactly this. If the directory query missed a group, the per-op allow rows for admin // could pass for the wrong reason (e.g. through lax-mode fallback), so this test // pins the resolution explicitly in strict mode. if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893."); var auth = await NewAuthenticator().AuthenticateAsync("admin", "admin123", TestContext.Current.CancellationToken); auth.Success.ShouldBeTrue(); auth.Groups.ShouldContain("ReadOnly"); auth.Groups.ShouldContain("WriteOperate"); auth.Groups.ShouldContain("WriteTune"); auth.Groups.ShouldContain("WriteConfigure"); auth.Groups.ShouldContain("AlarmAck"); } [Fact] public async Task Failed_Bind_Returns_Empty_Groups_And_Empty_Roles() { // Failure path must not surface any group claims — the gate would be misled into // resolving permissions for a user who never authenticated. if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893."); var auth = await NewAuthenticator().AuthenticateAsync("readonly", "wrong-password", TestContext.Current.CancellationToken); auth.Success.ShouldBeFalse(); auth.Groups.ShouldBeEmpty(); auth.Roles.ShouldBeEmpty(); } }