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>
68 lines
3.1 KiB
C#
68 lines
3.1 KiB
C#
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");
|
|
}
|
|
}
|