Compare commits

...

2 Commits

Author SHA1 Message Date
Joseph Doherty
4886a5783f Phase 3 PR 31 — Live-LDAP integration test + Active Directory compatibility. Closes LMX follow-up #4 with 6 live-bind tests in Server.Tests/LdapUserAuthenticatorLiveTests.cs against the dev GLAuth instance at localhost:3893 (skipped cleanly when unreachable via Assert.Skip + a clear SkipReason — matches the GalaxyRepositoryLiveSmokeTests pattern). Coverage: valid credentials bind + surface DisplayName; wrong password fails; unknown user fails; empty credentials fail pre-flight without touching the directory; writeop user's memberOf maps through GroupToRole to WriteOperate (the exact string WriteAuthzPolicy.IsAllowed expects); admin user surfaces all four mapped roles (WriteOperate + WriteTune + WriteConfigure + AlarmAck) proving memberOf parsing doesn't stop after the first match. While wiring this up, the authenticator's hard-coded user-lookup filter 'uid=<name>' didn't match GLAuth (which keys users by cn and doesn't populate uid) — AND it doesn't match Active Directory either, which uses sAMAccountName. Added UserNameAttribute to LdapOptions (default 'uid' for RFC 2307 backcompat) so deployments override to 'cn' / 'sAMAccountName' / 'userPrincipalName' as the directory requires; authenticator filter now interpolates the configured attribute. The default stays 'uid' so existing test fixtures and OpenLDAP installs keep working without a config change — a regression guard in LdapUserAuthenticatorAdCompatTests.LdapOptions_default_UserNameAttribute_is_uid_for_rfc2307_compat pins this so a future 'helpful' default change can't silently break anyone.
Active Directory compatibility. LdapOptions xml-doc expanded with a cheat-sheet covering Server (DC FQDN), Port 389 vs 636, UseTls=true under AD LDAP-signing enforcement, dedicated read-only service account DN, sAMAccountName vs userPrincipalName vs cn trade-offs, memberOf DN shape (CN=Group,OU=...,DC=... with the CN= RDN stripped to become the GroupToRole key), and the explicit 'nested groups NOT expanded' call-out (LDAP_MATCHING_RULE_IN_CHAIN / tokenGroups is a future authenticator enhancement, not a config change). docs/security.md §'Active Directory configuration' adds a complete appsettings.json snippet with realistic AD group names (OPCUA-Operators → WriteOperate, OPCUA-Engineers → WriteConfigure, OPCUA-AlarmAck → AlarmAck, OPCUA-Tuners → WriteTune), LDAPS port 636, TLS on, insecure-LDAP off, and operator-facing notes on each field. LdapUserAuthenticatorAdCompatTests (5 unit guards): ExtractFirstRdnValue parses AD-style 'CN=OPCUA-Operators,OU=...,DC=...' DNs correctly (case-preserving — operators' GroupToRole keys stay readable); also handles mixed case and spaces in group names ('Domain Users'); also works against the OpenLDAP ou=<group>,ou=groups shape (GLAuth) so one extractor tolerates both memberOf formats common in the field; EscapeLdapFilter escapes the RFC 4515 injection set (\, *, (, ), \0) so a malicious login like 'admin)(cn=*' can't break out of the filter; default UserNameAttribute regression guard.
Test posture — Server.Tests Unit: 43 pass / 0 fail (38 prior + 5 new AD-compat guards). Server.Tests LiveLdap category: 6 pass / 0 fail against running GLAuth (would skip cleanly without). Server build clean, 0 errors, 0 warnings.
Deferred: the session-identity end-to-end check (drive a full OPC UA UserName session, then read a 'whoami' node to verify the role landed on RoleBasedIdentity). That needs a test-only address-space node and is scoped for a separate PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:23:22 -04:00
d70a2e0077 Merge pull request 'Phase 3 PR 30 — Modbus integration-test project scaffold + DL205 smoke test' (#29) from phase-3-pr30-modbus-integration-scaffold into v2 2026-04-18 15:08:45 -04:00
6 changed files with 321 additions and 15 deletions

View File

@@ -348,6 +348,44 @@ The project uses [GLAuth](https://github.com/glauth/glauth) v2.4.0 as the LDAP s
Enable LDAP in `appsettings.json` under `Authentication.Ldap`. See [Configuration Guide](Configuration.md) for the full property reference.
### Active Directory configuration
Production deployments typically point at Active Directory instead of GLAuth. Only four properties differ from the dev defaults: `Server`, `Port`, `UserNameAttribute`, and `ServiceAccountDn`. The same `GroupToRole` mechanism works — map your AD security groups to OPC UA roles.
```json
{
"OpcUaServer": {
"Ldap": {
"Enabled": true,
"Server": "dc01.corp.example.com",
"Port": 636,
"UseTls": true,
"AllowInsecureLdap": false,
"SearchBase": "DC=corp,DC=example,DC=com",
"ServiceAccountDn": "CN=OpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com",
"ServiceAccountPassword": "<from your secret store>",
"DisplayNameAttribute": "displayName",
"GroupAttribute": "memberOf",
"UserNameAttribute": "sAMAccountName",
"GroupToRole": {
"OPCUA-Operators": "WriteOperate",
"OPCUA-Engineers": "WriteConfigure",
"OPCUA-AlarmAck": "AlarmAck",
"OPCUA-Tuners": "WriteTune"
}
}
}
}
```
Notes:
- `UserNameAttribute: "sAMAccountName"` is the critical AD override — the default `uid` is not populated on AD user entries, so the user-DN lookup returns no results without it. Use `userPrincipalName` instead if operators log in with `user@corp.example.com` form.
- `Port: 636` + `UseTls: true` is required under AD's LDAP-signing enforcement. AD increasingly rejects plain-LDAP bind; set `AllowInsecureLdap: false` to refuse fallback.
- `ServiceAccountDn` should name a dedicated read-only service principal — not a privileged admin. The account needs read access to user and group entries in the search base.
- `memberOf` values come back as full DNs like `CN=OPCUA-Operators,OU=OPC UA Security Groups,OU=Groups,DC=corp,DC=example,DC=com`. The authenticator strips the leading `CN=` RDN value so operators configure `GroupToRole` with readable group common-names.
- Nested group membership is **not** expanded — assign users directly to the role-mapped groups, or pre-flatten membership in AD. `LDAP_MATCHING_RULE_IN_CHAIN` / `tokenGroups` expansion is an authenticator enhancement, not a config change.
### Security Considerations
- LDAP credentials are transmitted in plaintext over the OPC UA channel unless transport security is enabled. Use `Basic256Sha256-SignAndEncrypt` for production deployments.

View File

@@ -58,18 +58,25 @@ Deferred: flipping `AutoAcceptUntrustedClientCertificates` to `false` as the
deployment default. That's a production-hardening config change, not a code
gap — the Admin UI is now ready to be the trust gate.
## 4. Live-LDAP integration test
## 4. Live-LDAP integration test — **DONE (PR 31)**
**Status**: PR 19 unit-tested the auth-flow shape; the live bind path is
exercised only by the pre-existing `Admin.Tests/LdapLiveBindTests.cs` which
uses the same Novell library against a running GLAuth at `localhost:3893`.
PR 31 shipped `Server.Tests/LdapUserAuthenticatorLiveTests.cs` — 6 live-bind
tests against the dev GLAuth instance at `localhost:3893`, skipped cleanly
when the port is unreachable. Covers: valid bind, wrong password, unknown
user, empty credentials, single-group → WriteOperate mapping, multi-group
admin user surfacing all mapped roles.
**To do**:
- Add `OpcUaServerIntegrationTests.Valid_username_authenticates_against_live_ldap`
with the same skip-when-unreachable guard.
- Assert `session.Identity` on the server side carries the expected role
after bind — requires exposing a test hook or reading identity from a
new `IHostConnectivityProbe`-style "whoami" variable in the address space.
Also added `UserNameAttribute` to `LdapOptions` (default `uid` for RFC 2307
compat) so Active Directory deployments can configure `sAMAccountName` /
`userPrincipalName` without code changes. `LdapUserAuthenticatorAdCompatTests`
(5 unit guards) pins the AD-shape DN parsing + filter escape behaviors. See
`docs/security.md` §"Active Directory configuration" for the AD appsettings
snippet.
Deferred: asserting `session.Identity` end-to-end on the server side (i.e.
drive a full OPC UA session with username/password, then read an
`IHostConnectivityProbe`-style "whoami" node to verify the role surfaced).
That needs a test-only address-space node and is a separate PR.
## 5. Full Galaxy live-service smoke test against the merged v2 stack

View File

@@ -2,11 +2,37 @@ namespace ZB.MOM.WW.OtOpcUa.Server.Security;
/// <summary>
/// LDAP settings for the OPC UA server's UserName token validator. Bound from
/// <c>appsettings.json</c> <c>OpcUaServer:Ldap</c>. Defaults match the GLAuth dev instance
/// (localhost:3893, dc=lmxopcua,dc=local). Production deployments set <see cref="UseTls"/>
/// true, populate <see cref="ServiceAccountDn"/> for search-then-bind, and maintain
/// <see cref="GroupToRole"/> with the real LDAP group names.
/// <c>appsettings.json</c> <c>OpcUaServer:Ldap</c>. Defaults target the GLAuth dev instance
/// (localhost:3893, <c>dc=lmxopcua,dc=local</c>) for the stock inner-loop setup. Production
/// deployments are expected to point at Active Directory; see <see cref="UserNameAttribute"/>
/// and the per-field xml-docs for the AD-specific overrides.
/// </summary>
/// <remarks>
/// <para><b>Active Directory cheat-sheet</b>:</para>
/// <list type="bullet">
/// <item><see cref="Server"/>: one of the domain controllers, or the domain FQDN (will round-robin DCs).</item>
/// <item><see cref="Port"/>: <c>389</c> (LDAP) or <c>636</c> (LDAPS); use 636 + <see cref="UseTls"/> in production.</item>
/// <item><see cref="UseTls"/>: <c>true</c>. AD increasingly rejects plain-LDAP bind under LDAP-signing enforcement.</item>
/// <item><see cref="AllowInsecureLdap"/>: <c>false</c>. Dev escape hatch only.</item>
/// <item><see cref="SearchBase"/>: <c>DC=corp,DC=example,DC=com</c> — your domain's base DN.</item>
/// <item><see cref="ServiceAccountDn"/>: a dedicated service principal with read access to user + group entries
/// (e.g. <c>CN=OpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com</c>). Never a privileged admin.</item>
/// <item><see cref="UserNameAttribute"/>: <c>sAMAccountName</c> (classic login name) or <c>userPrincipalName</c>
/// (user@domain form). Default is <c>uid</c> which AD does <b>not</b> populate, so this override is required.</item>
/// <item><see cref="DisplayNameAttribute"/>: <c>displayName</c> gives the human name; <c>cn</c> works too but is less rich.</item>
/// <item><see cref="GroupAttribute"/>: <c>memberOf</c> — matches AD's default. Values are full DNs
/// (<c>CN=&lt;Group&gt;,OU=...,DC=...</c>); the authenticator strips the leading <c>CN=</c> RDN value and uses
/// that as the lookup key in <see cref="GroupToRole"/>.</item>
/// <item><see cref="GroupToRole"/>: maps your AD group common-names to OPC UA roles — e.g.
/// <c>{"OPCUA-Operators" : "WriteOperate", "OPCUA-Engineers" : "WriteConfigure"}</c>.</item>
/// </list>
/// <para>
/// Nested groups are <b>not</b> expanded — AD's <c>tokenGroups</c> / <c>LDAP_MATCHING_RULE_IN_CHAIN</c>
/// membership-chain filter isn't used. Assign users directly to the role-mapped groups, or pre-flatten
/// membership in your directory. If nested expansion becomes a requirement, it's an authenticator
/// enhancement (not a config change).
/// </para>
/// </remarks>
public sealed class LdapOptions
{
public bool Enabled { get; init; } = false;
@@ -23,6 +49,20 @@ public sealed class LdapOptions
public string DisplayNameAttribute { get; init; } = "cn";
public string GroupAttribute { get; init; } = "memberOf";
/// <summary>
/// LDAP attribute used to match a login name against user entries in the directory.
/// Defaults to <c>uid</c> (RFC 2307). Common overrides:
/// <list type="bullet">
/// <item><c>sAMAccountName</c> — Active Directory, classic NT-style login names (e.g. <c>jdoe</c>).</item>
/// <item><c>userPrincipalName</c> — Active Directory, email-style (e.g. <c>jdoe@corp.example.com</c>).</item>
/// <item><c>cn</c> — GLAuth + some OpenLDAP deployments where users are keyed by common-name.</item>
/// </list>
/// Used only when <see cref="ServiceAccountDn"/> is non-empty (search-then-bind path) —
/// direct-bind fallback constructs the DN as <c>cn=&lt;name&gt;,&lt;SearchBase&gt;</c>
/// regardless of this setting and is not a production-grade path against AD.
/// </summary>
public string UserNameAttribute { get; init; } = "uid";
/// <summary>
/// LDAP group → OPC UA role. Each authenticated user gets every role whose source group
/// is in their membership list. Recognized role names (CLAUDE.md): <c>ReadOnly</c> (browse

View File

@@ -106,7 +106,7 @@ public sealed class LdapUserAuthenticator(LdapOptions options, ILogger<LdapUserA
{
await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct);
var filter = $"(uid={EscapeLdapFilter(username)})";
var filter = $"({options.UserNameAttribute}={EscapeLdapFilter(username)})";
var results = await Task.Run(() =>
conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);

View File

@@ -0,0 +1,67 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// Deterministic guards for Active Directory compatibility of the internal helpers
/// <see cref="LdapUserAuthenticator"/> relies on. We can't live-bind against AD in unit
/// tests — instead, we pin the behaviors AD depends on (DN-parsing of AD-style
/// <c>memberOf</c> values, filter escaping with case-preserving RDN extraction) so a
/// future refactor can't silently break the AD path while the GLAuth live-smoke stays
/// green.
/// </summary>
[Trait("Category", "Unit")]
public sealed class LdapUserAuthenticatorAdCompatTests
{
[Fact]
public void ExtractFirstRdnValue_parses_AD_memberOf_group_name_from_CN_dn()
{
// AD's memberOf values use uppercase CN=… and full domain paths. The extractor
// returns the first RDN's value regardless of attribute-type case, so operators'
// GroupToRole keys stay readable ("OPCUA-Operators" not "CN=OPCUA-Operators,...").
var dn = "CN=OPCUA-Operators,OU=OPC UA Security Groups,OU=Groups,DC=corp,DC=example,DC=com";
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("OPCUA-Operators");
}
[Fact]
public void ExtractFirstRdnValue_handles_mixed_case_and_spaces_in_group_name()
{
var dn = "CN=Domain Users,CN=Users,DC=corp,DC=example,DC=com";
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("Domain Users");
}
[Fact]
public void ExtractFirstRdnValue_also_works_for_OpenLDAP_ou_style_memberOf()
{
// GLAuth + some OpenLDAP deployments expose memberOf as ou=<group>,ou=groups,...
// The authenticator needs one extractor that tolerates both shapes since directories
// in the field mix them depending on schema.
var dn = "ou=WriteOperate,ou=groups,dc=lmxopcua,dc=local";
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("WriteOperate");
}
[Fact]
public void EscapeLdapFilter_prevents_injection_via_samaccountname_lookup()
{
// AD login names can contain characters that are meaningful to LDAP filter syntax
// (parens, backslashes). The authenticator builds filters as
// ($"({UserNameAttribute}={EscapeLdapFilter(username)})") so injection attempts must
// not break out of the filter. The RFC 4515 escape set is: \ → \5c, * → \2a, ( → \28,
// ) → \29, \0 → \00.
LdapUserAuthenticator.EscapeLdapFilter("admin)(cn=*")
.ShouldBe("admin\\29\\28cn=\\2a");
LdapUserAuthenticator.EscapeLdapFilter("domain\\user")
.ShouldBe("domain\\5cuser");
}
[Fact]
public void LdapOptions_default_UserNameAttribute_is_uid_for_rfc2307_compat()
{
// Regression guard: PR 31 introduced UserNameAttribute with a default of "uid" so
// existing deployments (pre-AD config) keep working. Changing the default breaks
// everyone's config silently; require an explicit review.
new LdapOptions().UserNameAttribute.ShouldBe("uid");
}
}

View File

@@ -0,0 +1,154 @@
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// Live-service tests against the dev GLAuth instance at <c>localhost:3893</c>. Skipped
/// when the port is unreachable so the test suite stays portable on boxes without a
/// running directory. Closes LMX follow-up #4 — the server-side <see cref="LdapUserAuthenticator"/>
/// is exercised end-to-end against a real LDAP server (same one the Admin process uses),
/// not just the flow-shape unit tests from PR 19.
/// </summary>
/// <remarks>
/// The <c>Admin.Tests</c> project already has a live-bind test for its own
/// <c>LdapAuthService</c>; this pair catches divergence between the two bind paths — the
/// Server authenticator has to work even when the Server process is on a machine that
/// doesn't have the Admin assemblies loaded, and the two share no code by design
/// (cross-app dependency avoidance). If one side drifts past the other on LDAP filter
/// construction, DN resolution, or memberOf parsing, these tests surface it.
/// </remarks>
[Trait("Category", "LiveLdap")]
public sealed class LdapUserAuthenticatorLiveTests
{
private const string GlauthHost = "localhost";
private const int GlauthPort = 3893;
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; }
}
// GLAuth dev directory groups are named identically to the OPC UA roles
// (ReadOnly / WriteOperate / WriteTune / WriteConfigure / AlarmAck), so the map is an
// identity translation. The authenticator still exercises every step of the pipeline —
// bind, memberOf lookup, group-name extraction, GroupToRole lookup — against real LDAP
// data; the identity map just means the assertion is phrased with no surprise rename
// in the middle.
private static LdapOptions GlauthOptions() => new()
{
Enabled = true,
Server = GlauthHost,
Port = GlauthPort,
UseTls = false,
AllowInsecureLdap = true,
SearchBase = "dc=lmxopcua,dc=local",
// Search-then-bind: service account resolves the user's full DN (cn=<user> lives
// under ou=<primary-group>,ou=users), the authenticator binds that DN with the
// user's password, then stays on the service-account session for memberOf lookup.
// Without this path, GLAuth ACLs block the authenticated user from reading their
// own entry in full — a plain self-search returns zero results and the role list
// ends up empty.
ServiceAccountDn = "cn=serviceaccount,dc=lmxopcua,dc=local",
ServiceAccountPassword = "serviceaccount123",
DisplayNameAttribute = "cn",
GroupAttribute = "memberOf",
UserNameAttribute = "cn", // GLAuth keys users by cn — see LdapOptions xml-doc.
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);
[Fact]
public async Task Valid_credentials_bind_and_return_success()
{
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
var result = await NewAuthenticator().AuthenticateAsync("readonly", "readonly123", TestContext.Current.CancellationToken);
result.Success.ShouldBeTrue(result.Error);
result.DisplayName.ShouldNotBeNullOrEmpty();
}
[Fact]
public async Task Writeop_user_gets_WriteOperate_role_from_group_mapping()
{
// Drives end-to-end: bind as writeop, memberOf lists the WriteOperate group, the
// authenticator surfaces WriteOperate via GroupToRole. If this test fails,
// WriteAuthzPolicy.IsAllowed for an Operate-tier write would also fail
// (WriteOperate is the exact string the policy checks for), so the failure mode is
// concrete, not abstract.
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
var result = await NewAuthenticator().AuthenticateAsync("writeop", "writeop123", TestContext.Current.CancellationToken);
result.Success.ShouldBeTrue(result.Error);
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
}
[Fact]
public async Task Admin_user_gets_multiple_roles_from_multiple_groups()
{
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
// 'admin' has primarygroup=ReadOnly and othergroups=[WriteOperate, AlarmAck,
// WriteTune, WriteConfigure] per the GLAuth dev config — the authenticator must
// surface every mapped role, not just the primary group. Guards against a regression
// where the memberOf parsing stops after the first match or misses the primary-group
// fallback.
var result = await NewAuthenticator().AuthenticateAsync("admin", "admin123", TestContext.Current.CancellationToken);
result.Success.ShouldBeTrue(result.Error);
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteTune);
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteConfigure);
result.Roles.ShouldContain("AlarmAck");
}
[Fact]
public async Task Wrong_password_returns_failure()
{
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
var result = await NewAuthenticator().AuthenticateAsync("readonly", "wrong-pw", TestContext.Current.CancellationToken);
result.Success.ShouldBeFalse();
result.Error.ShouldNotBeNullOrEmpty();
}
[Fact]
public async Task Unknown_user_returns_failure()
{
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
var result = await NewAuthenticator().AuthenticateAsync("no-such-user-42", "whatever", TestContext.Current.CancellationToken);
result.Success.ShouldBeFalse();
}
[Fact]
public async Task Empty_credentials_fail_without_touching_the_directory()
{
// Pre-flight guard — doesn't require GLAuth.
var result = await NewAuthenticator().AuthenticateAsync("", "", TestContext.Current.CancellationToken);
result.Success.ShouldBeFalse();
result.Error.ShouldContain("Credentials", Case.Insensitive);
}
}