diff --git a/docs/security.md b/docs/security.md index e07f01c..c185a56 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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": "", + "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. diff --git a/docs/v2/lmx-followups.md b/docs/v2/lmx-followups.md index 47c729f..6d6aa82 100644 --- a/docs/v2/lmx-followups.md +++ b/docs/v2/lmx-followups.md @@ -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 diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapOptions.cs index 756dfe7..6dbd411 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapOptions.cs @@ -2,11 +2,37 @@ namespace ZB.MOM.WW.OtOpcUa.Server.Security; /// /// LDAP settings for the OPC UA server's UserName token validator. Bound from -/// appsettings.json OpcUaServer:Ldap. Defaults match the GLAuth dev instance -/// (localhost:3893, dc=lmxopcua,dc=local). Production deployments set -/// true, populate for search-then-bind, and maintain -/// with the real LDAP group names. +/// appsettings.json OpcUaServer:Ldap. Defaults target the GLAuth dev instance +/// (localhost:3893, dc=lmxopcua,dc=local) for the stock inner-loop setup. Production +/// deployments are expected to point at Active Directory; see +/// and the per-field xml-docs for the AD-specific overrides. /// +/// +/// Active Directory cheat-sheet: +/// +/// : one of the domain controllers, or the domain FQDN (will round-robin DCs). +/// : 389 (LDAP) or 636 (LDAPS); use 636 + in production. +/// : true. AD increasingly rejects plain-LDAP bind under LDAP-signing enforcement. +/// : false. Dev escape hatch only. +/// : DC=corp,DC=example,DC=com — your domain's base DN. +/// : a dedicated service principal with read access to user + group entries +/// (e.g. CN=OpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com). Never a privileged admin. +/// : sAMAccountName (classic login name) or userPrincipalName +/// (user@domain form). Default is uid which AD does not populate, so this override is required. +/// : displayName gives the human name; cn works too but is less rich. +/// : memberOf — matches AD's default. Values are full DNs +/// (CN=<Group>,OU=...,DC=...); the authenticator strips the leading CN= RDN value and uses +/// that as the lookup key in . +/// : maps your AD group common-names to OPC UA roles — e.g. +/// {"OPCUA-Operators" : "WriteOperate", "OPCUA-Engineers" : "WriteConfigure"}. +/// +/// +/// Nested groups are not expanded — AD's tokenGroups / LDAP_MATCHING_RULE_IN_CHAIN +/// 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). +/// +/// 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"; + /// + /// LDAP attribute used to match a login name against user entries in the directory. + /// Defaults to uid (RFC 2307). Common overrides: + /// + /// sAMAccountName — Active Directory, classic NT-style login names (e.g. jdoe). + /// userPrincipalName — Active Directory, email-style (e.g. jdoe@corp.example.com). + /// cn — GLAuth + some OpenLDAP deployments where users are keyed by common-name. + /// + /// Used only when is non-empty (search-then-bind path) — + /// direct-bind fallback constructs the DN as cn=<name>,<SearchBase> + /// regardless of this setting and is not a production-grade path against AD. + /// + public string UserNameAttribute { get; init; } = "uid"; + /// /// LDAP group → OPC UA role. Each authenticated user gets every role whose source group /// is in their membership list. Recognized role names (CLAUDE.md): ReadOnly (browse diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs index 414aba6..744d89b 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs @@ -106,7 +106,7 @@ public sealed class LdapUserAuthenticator(LdapOptions options, ILogger 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); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/LdapUserAuthenticatorAdCompatTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/LdapUserAuthenticatorAdCompatTests.cs new file mode 100644 index 0000000..1366fa8 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/LdapUserAuthenticatorAdCompatTests.cs @@ -0,0 +1,67 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Server.Security; + +namespace ZB.MOM.WW.OtOpcUa.Server.Tests; + +/// +/// Deterministic guards for Active Directory compatibility of the internal helpers +/// 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 +/// memberOf 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. +/// +[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=,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"); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/LdapUserAuthenticatorLiveTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/LdapUserAuthenticatorLiveTests.cs new file mode 100644 index 0000000..0aeabf1 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/LdapUserAuthenticatorLiveTests.cs @@ -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; + +/// +/// Live-service tests against the dev GLAuth instance at localhost:3893. 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 +/// 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. +/// +/// +/// The Admin.Tests project already has a live-bind test for its own +/// LdapAuthService; 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. +/// +[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= lives + // under ou=,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.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); + } +}