diff --git a/ZB.MOM.WW.Auth/Directory.Build.props b/ZB.MOM.WW.Auth/Directory.Build.props index 46a66f9..7e57b98 100644 --- a/ZB.MOM.WW.Auth/Directory.Build.props +++ b/ZB.MOM.WW.Auth/Directory.Build.props @@ -5,10 +5,12 @@ enable enable latest - - 0.2.1 + + 0.3.0 true diff --git a/ZB.MOM.WW.Auth/README.md b/ZB.MOM.WW.Auth/README.md index 36a3c3d..1fc55f3 100644 --- a/ZB.MOM.WW.Auth/README.md +++ b/ZB.MOM.WW.Auth/README.md @@ -11,17 +11,18 @@ Authentication and authorisation libraries for the **ZB.MOM.WW SCADA family** (O | `ZB.MOM.WW.Auth.Abstractions` | Auth contracts, canonical role constants, and shared types (`LdapOptions`, `LdapAuthResult`, `ILdapAuthService`, `IApiKeyStore`). No runtime dependencies beyond the BCL. | — | | `ZB.MOM.WW.Auth.Ldap` | LDAP authentication service: bind-then-search-then-bind against GLAuth or Active Directory; RFC 4514-aware group extraction; fail-closed. | `Abstractions`, `Novell.Directory.Ldap.NETStandard` | | `ZB.MOM.WW.Auth.ApiKeys` | SQLite-backed API-key store with **pepper-keyed HMAC-SHA256** secret hashing, rotation, and audit log. DI wiring is `AddZbApiKeyAuth`; an opt-in `MigrationHostedService` runs schema migrations on startup. | `Abstractions`, `Microsoft.Data.Sqlite` | +| `ZB.MOM.WW.Auth.ApiKeys.SqlServer` | SQL Server-backed API-key store sharing **one row set** across clustered hosts (e.g. a central pair), instead of each node keeping its own SQLite file. DI wiring is `AddZbApiKeyAuthSqlServer(config, apiKeySection, sqlServerSection)`, called **instead of** `AddZbApiKeyAuth` (it calls that internally). Also ships `SqliteApiKeyStoreImporter` — a one-shot per-node SQLite → shared store copy a host runs once at boot. | `Abstractions`, `ApiKeys`, `Microsoft.Data.SqlClient` | | `ZB.MOM.WW.Auth.AspNetCore` | ASP.NET Core wiring for the **LDAP** provider only: `AddZbLdapAuth` (binds + start-time-validates `LdapOptions`, registers `ILdapAuthService`), plus `ZbCookieDefaults.Apply` (hardened cookie helper the consumer calls itself) and `ZbClaimTypes` constants. It does **not** wire API keys or cookie middleware — API-key DI is `AddZbApiKeyAuth` in the `ApiKeys` package. | `Abstractions`, `Ldap`, `Microsoft.AspNetCore.App` | --- ## Consumer Matrix -| Consumer | Abstractions | Ldap | ApiKeys | AspNetCore | -|---|:---:|:---:|:---:|:---:| -| **OtOpcUa** | yes | yes | — | yes | -| **MxAccessGateway** | yes | yes | yes | yes | -| **ScadaBridge** | yes | yes | yes | yes | +| Consumer | Abstractions | Ldap | ApiKeys | ApiKeys.SqlServer | AspNetCore | +|---|:---:|:---:|:---:|:---:|:---:| +| **OtOpcUa** | yes | yes | — | — | yes | +| **MxAccessGateway** | yes | yes | yes | — | yes | +| **ScadaBridge** | yes | yes | yes | yes (central pair, 2026-09-02) | yes | `ApiKeys` is NOT used by OtOpcUa (that app authenticates human operators via LDAP + cookies only; machine-to-machine access is out of scope). @@ -29,7 +30,25 @@ Authentication and authorisation libraries for the **ZB.MOM.WW SCADA family** (O ## Versioning -All four packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.1.4**. A single version bump in `Directory.Build.props` bumps all four 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.3.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. + +### 0.3.0 — SQL Server API-key store (ScadaBridge central pair) + +- **`IApiKeyStoreMigrator`** added to `Abstractions`: creates or upgrades an API-key store's schema; idempotent and safe to run concurrently from several nodes. `SqliteAuthStoreMigrator` implements it unchanged. `AddZbApiKeyAuth` registers the SQLite implementation via `TryAdd`, so a provider that registers its own migrator first (see below) wins — that ordering is the whole mechanism by which a different backend is swapped in. +- **New package `ZB.MOM.WW.Auth.ApiKeys.SqlServer`** + `AddZbApiKeyAuthSqlServer(config, apiKeySection, sqlServerSection)`, called instead of `AddZbApiKeyAuth` (it calls it internally after registering the SQL Server stores and migrator first). +- **`zbauth` schema v1** (three tables: `schema_version`, `api_keys`, `api_key_audit`; `DATETIMEOFFSET(7)` timestamps). `SqlServerApiKeyStoreMigrator` is concurrent-boot safe — a central pair's two nodes both migrate at every boot — with retry on SQL error numbers 1205/2714/1913/2627 and jittered backoff (measured: the flat backoff it replaced exhausted its retries roughly 1 in 60 eight-way boots). DBA-facing DDL: `docs/sql/zbauth-schema.sql`. +- **`SqliteApiKeyStoreImporter`** — idempotent insert-where-not-exists copy of an existing node's per-node SQLite keys and audit rows into the shared store; a row already present in SQL Server is never modified, so running it from both nodes of a pair converges on the union of their files. +- **`ApiKeyAdminCommands`** gained a ctor taking `IApiKeyStoreMigrator`; the existing ctor taking the concrete `SqliteAuthStoreMigrator` is retained (MxAccessGateway, HistorianGateway and ScadaBridge all construct it that way). +- SQLite remains the default store; **existing consumers need no code change to move to 0.3.0**. +- Live tests for the SQL Server package are gated on `AUTH_SQLSERVER_CONNSTR` and skip (not fail) when it is unset. + +### 0.2.1 — LDAP AD continuation-referral fix + +- `NovellLdapConnection.Search` now skips Active Directory continuation referrals (`LdapReferralException` from `Next()`) instead of letting one fail the whole search — a `SearchBase` at a domain root made every login fail as `ServiceAccountBindFailed` against a healthy directory. Non-referral faults still propagate untouched. + +### 0.2.0 — version bump only + +- No functional change; bumped the lockstep family version ahead of the 0.2.1 LDAP fix. ### 0.1.4 — API-key expiry (archreview G-2) diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/DependencyInjection/SqlServerApiKeyServiceCollectionExtensions.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/DependencyInjection/SqlServerApiKeyServiceCollectionExtensions.cs new file mode 100644 index 0000000..5e783f5 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/DependencyInjection/SqlServerApiKeyServiceCollectionExtensions.cs @@ -0,0 +1,96 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.Auth.Abstractions.ApiKeys; +using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection; + +namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.DependencyInjection; + +/// +/// Dependency-injection entry point that swaps the API-key store backend from the SQLite default +/// to a shared SQL Server database — one row set read and written by every node of a cluster (e.g. +/// a ScadaBridge central pair), instead of the per-node file alone +/// would give each node. +/// +public static class SqlServerApiKeyServiceCollectionExtensions +{ + /// + /// Registers the SQL Server-backed , , + /// and , then calls + /// to complete the registration + /// (verifier, pepper provider, migration hosted service). + /// + /// + /// + /// Call this INSTEAD of , + /// never both — this method calls it internally. The SQL Server stores and migrator are + /// registered here, BEFORE that inner call, precisely because + /// registers everything with + /// TryAdd*: whichever implementation of (etc.) is registered + /// first wins, and a later TryAdd for the same service is a no-op. That ordering is the + /// entire mechanism by which this method displaces the SQLite default. + /// + /// + /// still registers a SQLite + /// connection factory from ApiKeyOptions.SqlitePath even when SQL Server wins every + /// store slot. That is harmless — the SQLite factory is lazy and the connection it would open + /// is never opened when nothing resolves it — and it is actually useful: it keeps the one path + /// a node's pre-existing per-node SQLite file lives at in a single option, which is exactly the + /// source path needs for the one-shot copy into the + /// shared store. + /// + /// + /// is registered here so a host can resolve it and run + /// it once at boot (e.g. from a startup task or its own hosted service) to copy an existing + /// node's per-node keys and audit rows into the now-shared store; it performs no work by being + /// merely registered. + /// + /// + /// The connection string must be a literal value or an environment-variable override — never + /// a ${secret:} reference on a host whose secret store is itself SQL-backed: resolving + /// that reference would require a working secret store, which on such a host requires this very + /// database to already be reachable, an unbreakable circular dependency at boot. + /// + /// + /// The service collection to add to. + /// The application configuration. + /// Path of the configuration section holding the core API-key options. + /// Path of the configuration section holding . + /// The same instance, for chaining. + public static IServiceCollection AddZbApiKeyAuthSqlServer( + this IServiceCollection services, + IConfiguration config, + string apiKeySectionPath, + string sqlServerSectionPath) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(config); + ArgumentException.ThrowIfNullOrWhiteSpace(apiKeySectionPath); + ArgumentException.ThrowIfNullOrWhiteSpace(sqlServerSectionPath); + + services.Configure(config.GetSection(sqlServerSectionPath)); + + services.TryAddSingleton(sp => + new AuthSqlServerConnectionFactory(sp.GetRequiredService>().Value)); + + // Registered BEFORE AddZbApiKeyAuth so its TryAdd calls find these already present and + // leave them alone — that is the whole mechanism by which the SQL Server store displaces + // SQLite (see the class remarks). + services.TryAddSingleton(sp => + new SqlServerApiKeyStore(sp.GetRequiredService())); + services.TryAddSingleton(sp => + new SqlServerApiKeyAdminStore(sp.GetRequiredService())); + services.TryAddSingleton(sp => + new SqlServerApiKeyAuditStore(sp.GetRequiredService())); + + services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); + + // Registered so a host can resolve it and run the one-shot per-node SQLite -> shared store + // copy at boot; registering it here does no work on its own. + services.TryAddSingleton(); + + return services.AddZbApiKeyAuth(config, apiKeySectionPath); + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/SqlServerApiKeyServiceCollectionExtensionsTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/SqlServerApiKeyServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..baebc01 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/SqlServerApiKeyServiceCollectionExtensionsTests.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.Auth.Abstractions.ApiKeys; +using ZB.MOM.WW.Auth.ApiKeys.SqlServer.DependencyInjection; + +namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests; + +public sealed class SqlServerApiKeyServiceCollectionExtensionsTests +{ + private static IConfiguration MinimalConfig() => + new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["ApiKeys:TokenPrefix"] = "sbk", + ["ApiKeys:PepperSecretName"] = "Pepper", + ["Pepper"] = "0123456789abcdef", + ["ApiKeys:SqlServer:ConnectionString"] = "Server=unused;TrustServerCertificate=true", + }).Build(); + + [Fact] + public void AddZbApiKeyAuthSqlServer_DisplacesSqliteStoresAndMigrator() + { + var services = new ServiceCollection(); + services.AddZbApiKeyAuthSqlServer(MinimalConfig(), "ApiKeys", "ApiKeys:SqlServer"); + using ServiceProvider sp = services.BuildServiceProvider(); + + Assert.IsType(sp.GetRequiredService()); + Assert.IsType(sp.GetRequiredService()); + Assert.IsType(sp.GetRequiredService()); + Assert.IsType(sp.GetRequiredService()); + Assert.NotNull(sp.GetRequiredService()); + Assert.NotNull(sp.GetRequiredService()); + } + + [Fact] + public void AddZbApiKeyAuthSqlServer_BindsSqlServerOptionsFromSection() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["ApiKeys:TokenPrefix"] = "sbk", + ["ApiKeys:PepperSecretName"] = "Pepper", + ["Pepper"] = "0123456789abcdef", + ["ApiKeys:SqlServer:ConnectionString"] = "Server=my-shared-sql;TrustServerCertificate=true", + ["ApiKeys:SqlServer:Schema"] = "zbauth_custom", + }).Build(); + var services = new ServiceCollection(); + services.AddZbApiKeyAuthSqlServer(config, "ApiKeys", "ApiKeys:SqlServer"); + using ServiceProvider sp = services.BuildServiceProvider(); + + SqlServerApiKeyOptions options = sp.GetRequiredService>().Value; + Assert.Equal("Server=my-shared-sql;TrustServerCertificate=true", options.ConnectionString); + Assert.Equal("zbauth_custom", options.Schema); + } + + [Fact] + public void AddZbApiKeyAuthSqlServer_RegistersMigrationHostedService() + { + var services = new ServiceCollection(); + services.AddZbApiKeyAuthSqlServer(MinimalConfig(), "ApiKeys", "ApiKeys:SqlServer"); + using ServiceProvider sp = services.BuildServiceProvider(); + + IEnumerable hostedServices = sp.GetServices(); + Assert.Contains(hostedServices, s => s.GetType().Name == "ApiKeyMigrationHostedService"); + } +}