From e3d146aa21fe50d3e3d29d2b1ed026c2a3f47021 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 1 Oct 2026 01:27:27 -0400 Subject: [PATCH 1/3] fix(auth): canonical username from the entry; refuse empty passwords in both bind modes - Request UserNameAttribute in the search (de-duplicated, case-insensitive) and return its first value as LdapAuthResult.Username, falling back to the typed name (I2). - The empty-password guard now applies in ServiceAccount mode too: AD treats an empty-password simple bind as an unauthenticated bind and reports success (I3). - Restore the dropped explanatory comments in CompleteWithServiceAccount / SearchUser. --- .../Internal/LdapUserName.cs | 7 +- .../ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs | 79 ++++++++++++++----- .../FakeLdapConnection.cs | 18 ++++- .../LdapAuthServiceTests.cs | 41 ++++++++++ .../LdapAuthServiceUserBindTests.cs | 41 ++++++++++ 5 files changed, 162 insertions(+), 24 deletions(-) diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapUserName.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapUserName.cs index 8c500a6..0d03e8a 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapUserName.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapUserName.cs @@ -2,8 +2,11 @@ namespace ZB.MOM.WW.Auth.Ldap.Internal; /// /// Reduces a typed login to the bare account name used by : -/// one spelling flows into the bind template, the search filter and the returned username, so audit -/// rows never carry DOMAIN\jdoe for one sign-in and jdoe for the next. +/// the qualifier is stripped before the name reaches the bind template and the search filter, so audit +/// rows never carry DOMAIN\jdoe for one sign-in and jdoe for the next. Case is NOT +/// normalised here: the returned username is canonicalised from the directory entry's +/// UserNameAttribute value, so JDoe and jdoe also yield one identity; this name is +/// only the fallback when the entry has no such value. /// internal static class LdapUserName { diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs index 676cbd6..927edd8 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs @@ -145,16 +145,19 @@ public sealed class LdapAuthService : ILdapAuthService // filter and the returned result (avoids two identities for one person). username = username.Trim(); - // 3b. User-bind mode guards, before any network call. An empty password must never reach - // the directory: AD answers a simple bind with an empty password as an UNAUTHENTICATED - // bind and reports success, which would admit anyone. The name is reduced to the bare - // account name so the bind template, the search filter and the result share one spelling. - if (_options.BindMode == LdapBindMode.User) + // 3b. An empty password must never reach the directory, in EITHER bind mode: AD answers a + // simple bind with an empty password as an UNAUTHENTICATED bind and reports success — + // the user bind (or the ServiceAccount-mode re-bind as the user's DN) would admit anyone. + // Refused here, before any network call, so no consumer depends on its own edge check. + if (string.IsNullOrEmpty(password)) + return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials); + + // 3c. User-bind mode: reduce the name to the bare account name the bind template and the + // search filter share. (The returned username is canonicalised from the entry later.) + if (_options.BindMode == LdapBindMode.User + && !LdapUserName.TryNormalize(username, _options.AcceptDomainQualifiedNames, out username)) { - if (string.IsNullOrEmpty(password)) - return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials); - if (!LdapUserName.TryNormalize(username, _options.AcceptDomainQualifiedNames, out username)) - return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials); + return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials); } // 4. Walk the endpoint list, preferred one first. A single-endpoint configuration @@ -278,7 +281,8 @@ public sealed class LdapAuthService : ILdapAuthService private LdapAuthResult CompleteWithServiceAccount(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string password) { // 6. Service-account bind so we can search for the user's DN. A bind failure here is a - // service-account misconfiguration — DISTINCT from a user-credential failure. + // service-account misconfiguration — DISTINCT from a user-credential failure — so it + // maps to ServiceAccountBindFailed. try { conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword); @@ -288,7 +292,9 @@ public sealed class LdapAuthService : ILdapAuthService throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind"); } - // 7–8. Exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser. + // 7–8. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser. + // Both are authoritative answers from a healthy directory, so they end the walk. + // We never attempt a user bind against an ambiguous DN. var entries = SearchUser(conn, username); if (entries.Count == 0) return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound); @@ -297,7 +303,9 @@ public sealed class LdapAuthService : ILdapAuthService var entry = entries[0]; - // 9. User bind verifies the password; a failure is the user's bad credentials and final. + // 9. User bind: re-bind as the resolved DN to verify the password. A bind failure here is + // the end user's bad credentials — the directory answered, so this is returned directly + // and never retried against another endpoint. try { conn.Bind(entry.Dn, password); @@ -344,6 +352,10 @@ public sealed class LdapAuthService : ILdapAuthService /// Step 7: search for the user entry; a search fault is system-side. private IReadOnlyList SearchUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username) { + // A search failure is infrastructure (directory unreachable / unhealthy) — system-side. + // NOTE: the LdapAuthFailure enum has no DirectoryUnavailable value, so a failed search maps + // to ServiceAccountBindFailed, the closest system-side bucket (same limitation as + // Connect), until a DirectoryUnavailable value exists. try { var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})"; @@ -355,7 +367,12 @@ public sealed class LdapAuthService : ILdapAuthService } } - /// Steps 10–11: fail-closed group extraction, then success. + /// + /// Steps 10–11: fail-closed group extraction, then success. The returned username is the + /// entry's own value (the directory's spelling), so + /// JDoe and jdoe sign in as one identity; the typed (normalised) name is the + /// fallback when the entry carries no such value. + /// private LdapAuthResult SucceedFromEntry(LdapSearchEntry entry, string username) { IReadOnlyList groups; @@ -371,7 +388,8 @@ public sealed class LdapAuthService : ILdapAuthService if (groups.Count == 0) return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed); - return LdapAuthResult.Success(username, ExtractDisplayName(entry, username), groups); + var canonical = ExtractUserName(entry, username); + return LdapAuthResult.Success(canonical, ExtractDisplayName(entry, canonical), groups); } /// @@ -393,16 +411,37 @@ public sealed class LdapAuthService : ILdapAuthService } /// - /// Builds the distinct attribute list requested from the directory. The display-name - /// and group attributes are de-duplicated so we never request the same attribute twice - /// when an operator configures them to the same value. + /// Builds the distinct attribute list requested from the directory: display name, groups and + /// the username attribute (read back as the canonical username). They are de-duplicated, + /// case-insensitively, so we never request the same attribute twice when an operator + /// configures two of them to the same value. /// private IReadOnlyList BuildSearchAttributes() { - if (string.Equals(_options.DisplayNameAttribute, _options.GroupAttribute, StringComparison.OrdinalIgnoreCase)) - return new[] { _options.DisplayNameAttribute }; + var attributes = new List(3); + foreach (var name in new[] { _options.DisplayNameAttribute, _options.GroupAttribute, _options.UserNameAttribute }) + { + if (!attributes.Contains(name, StringComparer.OrdinalIgnoreCase)) + attributes.Add(name); + } - return new[] { _options.DisplayNameAttribute, _options.GroupAttribute }; + return attributes; + } + + /// + /// Returns the first non-blank value of the configured username attribute — the directory's + /// canonical spelling — falling back to the (already normalised) typed username. + /// + private string ExtractUserName(LdapSearchEntry entry, string username) + { + if (entry.Attributes.TryGetValue(_options.UserNameAttribute, out var values) + && values.Count > 0 + && !string.IsNullOrWhiteSpace(values[0])) + { + return values[0]; + } + + return username; } /// diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs index ddb3e23..5075200 100644 --- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs @@ -34,6 +34,9 @@ internal sealed class FakeLdapConnection : ILdapConnection /// public int BindAttempts { get; private set; } + /// The attribute list passed to the most recent call (the fake still returns every scripted attribute). + public IReadOnlyList? SearchAttributes { get; private set; } + /// Every call in order: connect, bind:<dn>, search:<filter>. public List Calls { get; } = new(); @@ -41,9 +44,14 @@ internal sealed class FakeLdapConnection : ILdapConnection /// /// Scripts a user entry that will be returned by the next call. - /// Builds a minimal attribute bag with memberOf and optional displayName. + /// Builds a minimal attribute bag with memberOf, optional displayName and any + /// (e.g. sAMAccountName) the test needs. /// - public FakeLdapConnection WithUserEntry(string dn, string[] memberOf, string? displayName = null) + public FakeLdapConnection WithUserEntry( + string dn, + string[] memberOf, + string? displayName = null, + IReadOnlyDictionary? extraAttributes = null) { var attrs = new Dictionary>(StringComparer.OrdinalIgnoreCase) { @@ -51,6 +59,11 @@ internal sealed class FakeLdapConnection : ILdapConnection }; if (displayName is not null) attrs["displayName"] = new[] { displayName }; + if (extraAttributes is not null) + { + foreach (var (name, values) in extraAttributes) + attrs[name] = values; + } _scriptedEntries.Add(new LdapSearchEntry(dn, attrs)); return this; @@ -177,6 +190,7 @@ internal sealed class FakeLdapConnection : ILdapConnection IReadOnlyList attributes) { Calls.Add($"search:{filter}"); + SearchAttributes = attributes; if (_throwOnSearch) throw new Novell.Directory.Ldap.LdapException( "Search failed", Novell.Directory.Ldap.LdapException.Unavailable, searchBase); diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceTests.cs index 44ff334..91d54d4 100644 --- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceTests.cs +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceTests.cs @@ -147,4 +147,45 @@ public class LdapAuthServiceTests Assert.True(r.Succeeded); Assert.Equal(new[] { "Eng,ineers", "A,B" }, r.Groups); } + + [Fact] + public async Task Username_IsCanonicalisedFromTheEntry_InServiceAccountMode() + { + var fake = new FakeLdapConnection().WithUserEntry( + "cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice", + new Dictionary { ["cn"] = new[] { "alice" } }); + var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake)); + + var r = await svc.AuthenticateAsync("ALICE", "pw", default); + + Assert.True(r.Succeeded); + Assert.Equal("alice", r.Username); + } + + [Fact] + public async Task Username_FallsBackToTheTypedName_InServiceAccountMode_WhenAttributeAbsent() + { + var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice"); + var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake)); + + var r = await svc.AuthenticateAsync("ALICE", "pw", default); + + Assert.True(r.Succeeded); + Assert.Equal("ALICE", r.Username); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public async Task EmptyPassword_IsRefused_InServiceAccountMode_WithoutContactingTheDirectory(string? password) + { + var factory = new SequenceLdapConnectionFactory( + new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice")); + var svc = new LdapAuthService(Opts(), factory); + + var r = await svc.AuthenticateAsync("alice", password!, default); + + Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure); + Assert.Empty(factory.Created); + } } diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceUserBindTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceUserBindTests.cs index 1276c1a..7350867 100644 --- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceUserBindTests.cs +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceUserBindTests.cs @@ -178,4 +178,45 @@ public class LdapAuthServiceUserBindTests Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure); } + + [Fact] + public async Task Username_IsCanonicalisedFromTheEntry_NotTheTypedCase() + { + var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithUserEntry( + "CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe", + new Dictionary { ["sAMAccountName"] = new[] { "jdoe" } })); + var result = await Login(new LdapAuthService(Opts(), factory), @"NAM\JDOE"); + + Assert.True(result.Succeeded); + Assert.Equal("jdoe", result.Username); + } + + [Fact] + public async Task Username_FallsBackToTheTypedName_WhenTheEntryLacksTheAttribute() + { + var factory = new SequenceLdapConnectionFactory(Jane()); + var result = await Login(new LdapAuthService(Opts(), factory), "JDOE"); + + Assert.True(result.Succeeded); + Assert.Equal("JDOE", result.Username); + } + + [Fact] + public async Task Search_RequestsTheUserNameAttribute() + { + var factory = new SequenceLdapConnectionFactory(Jane()); + await Login(new LdapAuthService(Opts(), factory), "jdoe"); + + Assert.Equal(new[] { "displayName", "memberOf", "sAMAccountName" }, factory.Created[0].SearchAttributes); + } + + [Fact] + public async Task Search_DeDuplicatesTheUserNameAttribute_CaseInsensitively() + { + var factory = new SequenceLdapConnectionFactory(Jane()); + var svc = new LdapAuthService(Opts() with { DisplayNameAttribute = "SAMACCOUNTNAME" }, factory); + await Login(svc, "jdoe"); + + Assert.Equal(new[] { "SAMACCOUNTNAME", "memberOf" }, factory.Created[0].SearchAttributes); + } } From 9090b1b5112f3bd45148190dda6606521af31c13 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 1 Oct 2026 01:27:55 -0400 Subject: [PATCH 2/3] docs(auth): prefer the down-level UserBindTemplate; warn on UPN templates (I1) --- .../ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs index 95a2527..ec397bf 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs @@ -42,8 +42,14 @@ public sealed record LdapOptions /// /// only: the simple-bind identity, with {0} replaced by the - /// normalised login name — e.g. {0}@example.com (UPN) or EXAMPLE\{0}. Must contain - /// {0} exactly once. + /// normalised login name. Must contain {0} exactly once. + /// + /// Prefer the down-level form EXAMPLE\{0}: it maps exactly to the sAMAccountName the + /// group search uses, so the account that binds is the account whose groups are read. A UPN + /// template ({0}@example.com) is only correct where every account's UPN prefix equals its + /// sAMAccountName — AD resolves a UPN bind against the explicit userPrincipalName, so + /// a mismatch can fail sign-in or bind a different account than the one whose groups are read. + /// /// public string UserBindTemplate { get; init; } = ""; From b0582eb66a1bc7d46656c26fc7481ca90f01adf7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 1 Oct 2026 01:27:55 -0400 Subject: [PATCH 3/3] =?UTF-8?q?release(auth):=200.4.1=20=E2=80=94=20canoni?= =?UTF-8?q?cal=20username,=20empty=20passwords=20refused=20in=20both=20mod?= =?UTF-8?q?es,=20template=20guidance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- ZB.MOM.WW.Auth/Directory.Build.props | 4 +++- ZB.MOM.WW.Auth/README.md | 15 +++++++++++---- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 75fa909..29221cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,7 +159,7 @@ each project's **code-verified current state**, and the **gaps** between. See | Component | Status | Goal | Design | Implementation | |---|---|---|---|---| -| Auth (login / identity / authz) | Adopted. Feed carries **`0.4.0`** (2026-10-01; opt-in user bind) — the four apps still pin `0.3.0` (released 2026-09-02; 5 packages, newest `ZB.MOM.WW.Auth.ApiKeys.SqlServer`). **All four apps pin `0.3.0`** (2026-09-03, all on their default branch and pushed — and all four are DEPLOYED at those tips on DEV vd03 and QA vq03/vq04 since 2026-09-10: OtOpcUa `b8b4c18f`, mxgw `4f931e4`, HistorianGateway `d8d890c`; ScadaBridge already had it on `main` via the `inbound-api-key-shared-store` work, `e45c4af9`). **0.4.0 (2026-10-01) — opt-in LDAP user bind:** adds `LdapOptions.BindMode` (`ServiceAccount` default | `User`), `UserBindTemplate` and `AcceptDomainQualifiedNames`; in `User` mode `LdapAuthService` binds as the signing-in user and reads their own groups on the same connection, so no service account is needed. Bind failures map to `BadCredentials` (bad password, unusable name, or bind result 49) or `GroupLookupFailed` (zero/multiple entries or no groups after a good bind), other bind/connect/search errors fail over to the next endpoint, and `ServiceAccount` stays the default so no existing consumer needs to change. First consumer: BTDB Manager; ScadaBridge/HistorianGateway/OtOpcUa/MxAccessGateway stay on `0.3.0` until they move to AD. `0.2.1` is a functional fix, not housekeeping — `0.1.5` cannot log in against an AD domain root at all. `FallbackServers` (0.2.0) live-gated on the redundant local GLAuth pair. Version narrative, consumer matrix, `tools/LdapProbe`: [`ZB.MOM.WW.Auth/CLAUDE.md`](ZB.MOM.WW.Auth/CLAUDE.md). | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) | +| Auth (login / identity / authz) | Adopted. Feed carries **`0.4.0`** (2026-10-01; opt-in user bind) — the four apps still pin `0.3.0` (released 2026-09-02; 5 packages, newest `ZB.MOM.WW.Auth.ApiKeys.SqlServer`). **All four apps pin `0.3.0`** (2026-09-03, all on their default branch and pushed — and all four are DEPLOYED at those tips on DEV vd03 and QA vq03/vq04 since 2026-09-10: OtOpcUa `b8b4c18f`, mxgw `4f931e4`, HistorianGateway `d8d890c`; ScadaBridge already had it on `main` via the `inbound-api-key-shared-store` work, `e45c4af9`). **0.4.0 (2026-10-01) — opt-in LDAP user bind:** adds `LdapOptions.BindMode` (`ServiceAccount` default | `User`), `UserBindTemplate` and `AcceptDomainQualifiedNames`; in `User` mode `LdapAuthService` binds as the signing-in user and reads their own groups on the same connection, so no service account is needed. Bind failures map to `BadCredentials` (bad password, unusable name, or bind result 49) or `GroupLookupFailed` (zero/multiple entries or no groups after a good bind), other bind/connect/search errors fail over to the next endpoint, and `ServiceAccount` stays the default so no existing consumer needs to change. **0.4.1** fixes the review findings: `Username` is canonicalised from the entry's `UserNameAttribute`, empty passwords are refused in both bind modes (a `ServiceAccount`-mode behaviour change), and the docs prefer the down-level `EXAMPLE\{0}` template over UPN. First consumer: BTDB Manager; ScadaBridge/HistorianGateway/OtOpcUa/MxAccessGateway stay on `0.3.0` until they move to AD. `0.2.1` is a functional fix, not housekeeping — `0.1.5` cannot log in against an AD domain root at all. `FallbackServers` (0.2.0) live-gated on the redundant local GLAuth pair. Version narrative, consumer matrix, `tools/LdapProbe`: [`ZB.MOM.WW.Auth/CLAUDE.md`](ZB.MOM.WW.Auth/CLAUDE.md). | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) | | UI Theme (layout / tokens / components) | Adopted. Feed `0.2.0`–`0.4.1`; **five** consumers (the ignitionoee router joined), all on `0.4.1` and pushed (2026-08-11); Every deployed wonder box (DEV vd03 + QA vq03/vq04) runs its repo tip as of 2026-09-10, so the deployed Theme is 0.4.1 everywhere. **On bump, TRIM the local `.btn` block, don't delete it** — mxgw's carried more than sizing. Narrative: [`ZB.MOM.WW.Theme/CLAUDE.md`](ZB.MOM.WW.Theme/CLAUDE.md). | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) | | Health (readiness / liveness / active-node) | **Adopted** — lib `0.3.0`, all 4 apps reference it + wire `MapZbHealth`. `0.2.1`/`0.3.0` changed the active tier's behaviour (standby now answers **503**; active = oldest `Up` member via `ClusterActiveNode`, not `RoleLeader` — the two diverge after any restart), and both Akka apps' private copies were deleted in its favour (merged + pushed). Narrative: [`ZB.MOM.WW.Health/CLAUDE.md`](ZB.MOM.WW.Health/CLAUDE.md). | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) | | Observability (metrics / traces / logs) | **Adopted** (libs `0.1.0`, on the feed; **all 4 apps** call `AddZbTelemetry`; `AddZbSerilog` in all but ScadaBridge, which keeps its own factory by design) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) | diff --git a/ZB.MOM.WW.Auth/Directory.Build.props b/ZB.MOM.WW.Auth/Directory.Build.props index d8cc15d..b89fd0a 100644 --- a/ZB.MOM.WW.Auth/Directory.Build.props +++ b/ZB.MOM.WW.Auth/Directory.Build.props @@ -8,7 +8,9 @@ - 0.4.0 + + 0.4.1 true diff --git a/ZB.MOM.WW.Auth/README.md b/ZB.MOM.WW.Auth/README.md index 2be3643..d3b961c 100644 --- a/ZB.MOM.WW.Auth/README.md +++ b/ZB.MOM.WW.Auth/README.md @@ -30,16 +30,23 @@ Authentication and authorisation libraries for the **ZB.MOM.WW SCADA family** (O ## Versioning -All five packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.4.0**. A single version bump in `Directory.Build.props` bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages. +All five packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.4.1**. A single version bump in `Directory.Build.props` bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages. + +### 0.4.1 — LDAP review fixes + +- **Canonical username (both modes):** the search now also requests `UserNameAttribute`, and `LdapAuthResult.Username` is the entry's first value of it (the directory's spelling), so `JDoe` and `jdoe` sign in as one identity in consumers' audit columns. Falls back to the normalised typed name when the entry has no such value. +- **Behaviour change for `ServiceAccount`-mode consumers:** an empty (or null) password is now refused by the library as `BadCredentials` before any network call, in both bind modes. Previously only `User` mode refused it; in `ServiceAccount` mode the re-bind as the user's DN with an empty password is an unauthenticated bind that AD reports as success, so a consumer without its own edge check admitted anyone. +- **Template guidance:** prefer the down-level `UserBindTemplate` `EXAMPLE\{0}` — it maps exactly to the `sAMAccountName` the group search uses. A UPN template (`{0}@example.com`) is only correct where every account's UPN prefix equals its `sAMAccountName`; AD resolves UPN binds against the explicit `userPrincipalName`, so a mismatch can fail sign-in or bind a different account than the one whose groups are read. +- No `Abstractions` contract change. ### 0.4.0 — LDAP sign-in by user bind (opt-in) -- **`LdapOptions.BindMode`** (`ServiceAccount` default | `User`), **`UserBindTemplate`** (`{0}` = the login name, e.g. `{0}@example.com` or `EXAMPLE\{0}`) and **`AcceptDomainQualifiedNames`** (default `true`). +- **`LdapOptions.BindMode`** (`ServiceAccount` default | `User`), **`UserBindTemplate`** (`{0}` = the login name, e.g. `EXAMPLE\{0}` — preferred, see 0.4.1 — or `{0}@example.com`) and **`AcceptDomainQualifiedNames`** (default `true`). - In `User` mode `LdapAuthService` binds as the user; success authenticates them and the same connection reads their entry (`UserNameAttribute`, `DisplayNameAttribute`, `GroupAttribute`). No `ServiceAccountDn` / `ServiceAccountPassword` is needed or validated. - Failure mapping: empty password or an unusable name → `BadCredentials` with no network call (AD treats an empty-password simple bind as an unauthenticated success). Bind result 49 → `BadCredentials`, final — never retried on a fallback DC, so one wrong password is one lockout strike. Any other bind error, a connect failure or a search failure → the system-side bucket (`ServiceAccountBindFailed`) and the next endpoint. Zero/several entries or no groups after a good bind → `GroupLookupFailed`. -- `DOMAIN\name` / `name@suffix` are reduced to `name` (or refused when `AcceptDomainQualifiedNames` is `false`), so the result's `Username` has one spelling. +- `DOMAIN\name` / `name@suffix` are reduced to `name` (or refused when `AcceptDomainQualifiedNames` is `false`), so the result's `Username` has one spelling (0.4.1 also canonicalises its case from the entry). - No `Abstractions` contract change beyond the new enum and option properties; `ServiceAccount` mode is unchanged. -- `UserBindTemplate` output is used verbatim as the bind name, so use a UPN (`{0}@example.com`) or down-level (`EXAMPLE\{0}`) template; a DN template is not escaped. +- `UserBindTemplate` output is used verbatim as the bind name, so use a down-level (`EXAMPLE\{0}`, preferred) or UPN (`{0}@example.com`, only where every UPN prefix equals the `sAMAccountName`) template; a DN template is not escaped. ### 0.3.0 — SQL Server API-key store (ScadaBridge central pair)