Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
234 lines
11 KiB
C#
234 lines
11 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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:
|
|
/// <c>LdapUserAuthenticator</c> bind → resolved LDAP group list →
|
|
/// <see cref="AuthorizationGate.IsAllowed"/> against a seeded
|
|
/// <see cref="TriePermissionEvaluator"/> → expected allow/deny verdict.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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).
|
|
/// </para>
|
|
/// <para>
|
|
/// Closes the production gap surfaced while planning this test: <c>RoleBasedIdentity</c>
|
|
/// did not implement <see cref="ILdapGroupsBearer"/>, so <see cref="AuthorizationGate"/>
|
|
/// lax-mode-allowed every request because it never received resolved LDAP groups. After
|
|
/// this PR <see cref="UserAuthResult"/> carries <c>Groups</c> alongside <c>Roles</c> and
|
|
/// <c>RoleBasedIdentity</c> exposes them via the bearer interface.
|
|
/// </para>
|
|
/// <para>Skipped when GLAuth at <c>localhost:3893</c> is unreachable so the suite stays
|
|
/// portable.</para>
|
|
/// </remarks>
|
|
[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<LdapUserAuthenticator>.Instance);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<string> groups)
|
|
{
|
|
DisplayName = userName;
|
|
LdapGroups = groups;
|
|
}
|
|
public new string DisplayName { get; }
|
|
public IReadOnlyList<string> LdapGroups { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// End-to-end: bind via LDAP, observe the resolved groups, drive every
|
|
/// <see cref="OpcUaOperation"/> 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.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
}
|