feat(auth)!: 0.3.0 — ZB.MOM.WW.Auth.ApiKeys.SqlServer package (AddZbApiKeyAuthSqlServer, importer), README/changelog

The `!` marks the new package + version, not an API break — SQLite stays default and
existing consumers need no code change.

Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
This commit is contained in:
Joseph Doherty
2026-09-02 14:39:35 -04:00
parent dcee4199ab
commit de21669ad5
4 changed files with 193 additions and 10 deletions
+6 -4
View File
@@ -5,10 +5,12 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<!-- 0.2.1: NovellLdapConnection.Search now skips AD continuation referrals instead of
letting them fail the search stage. Without it, a SearchBase at a domain root made
every login fail as ServiceAccountBindFailed against a healthy directory. -->
<Version>0.2.1</Version>
<!-- 0.3.0: new ZB.MOM.WW.Auth.ApiKeys.SqlServer package — a shared SQL Server API-key store
for clustered hosts (e.g. a ScadaBridge central pair), fixing the per-node SQLite store
going out of sync on failover. Adds the IApiKeyStoreMigrator abstraction (SQLite
implements it unchanged). SQLite stays the default for every existing consumer; no
consumer code change is required to move to 0.3.0. -->
<Version>0.3.0</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
+25 -6
View File
@@ -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)
@@ -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;
/// <summary>
/// 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 <see cref="AddZbApiKeyAuth"/> alone
/// would give each node.
/// </summary>
public static class SqlServerApiKeyServiceCollectionExtensions
{
/// <summary>
/// Registers the SQL Server-backed <see cref="IApiKeyStore"/>, <see cref="IApiKeyAdminStore"/>,
/// <see cref="IApiKeyAuditStore"/> and <see cref="IApiKeyStoreMigrator"/>, then calls
/// <see cref="ApiKeyServiceCollectionExtensions.AddZbApiKeyAuth"/> to complete the registration
/// (verifier, pepper provider, migration hosted service).
/// </summary>
/// <remarks>
/// <para>
/// <b>Call this INSTEAD of <see cref="ApiKeyServiceCollectionExtensions.AddZbApiKeyAuth"/></b>,
/// never both — this method calls it internally. The SQL Server stores and migrator are
/// registered here, BEFORE that inner call, precisely because
/// <see cref="ApiKeyServiceCollectionExtensions.AddZbApiKeyAuth"/> registers everything with
/// <c>TryAdd*</c>: whichever implementation of <see cref="IApiKeyStore"/> (etc.) is registered
/// first wins, and a later <c>TryAdd</c> for the same service is a no-op. That ordering is the
/// entire mechanism by which this method displaces the SQLite default.
/// </para>
/// <para>
/// <see cref="ApiKeyServiceCollectionExtensions.AddZbApiKeyAuth"/> still registers a SQLite
/// connection factory from <c>ApiKeyOptions.SqlitePath</c> 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 <see cref="SqliteApiKeyStoreImporter"/> needs for the one-shot copy into the
/// shared store.
/// </para>
/// <para>
/// <see cref="SqliteApiKeyStoreImporter"/> 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.
/// </para>
/// <para>
/// <b>The connection string must be a literal value or an environment-variable override — never
/// a <c>${secret:}</c> reference</b> 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.
/// </para>
/// </remarks>
/// <param name="services">The service collection to add to.</param>
/// <param name="config">The application configuration.</param>
/// <param name="apiKeySectionPath">Path of the configuration section holding the core API-key options.</param>
/// <param name="sqlServerSectionPath">Path of the configuration section holding <see cref="SqlServerApiKeyOptions"/>.</param>
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
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<SqlServerApiKeyOptions>(config.GetSection(sqlServerSectionPath));
services.TryAddSingleton(sp =>
new AuthSqlServerConnectionFactory(sp.GetRequiredService<IOptions<SqlServerApiKeyOptions>>().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<IApiKeyStore>(sp =>
new SqlServerApiKeyStore(sp.GetRequiredService<AuthSqlServerConnectionFactory>()));
services.TryAddSingleton<IApiKeyAdminStore>(sp =>
new SqlServerApiKeyAdminStore(sp.GetRequiredService<AuthSqlServerConnectionFactory>()));
services.TryAddSingleton<IApiKeyAuditStore>(sp =>
new SqlServerApiKeyAuditStore(sp.GetRequiredService<AuthSqlServerConnectionFactory>()));
services.TryAddSingleton<SqlServerApiKeyStoreMigrator>();
services.TryAddSingleton<IApiKeyStoreMigrator>(sp => sp.GetRequiredService<SqlServerApiKeyStoreMigrator>());
// 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<SqliteApiKeyStoreImporter>();
return services.AddZbApiKeyAuth(config, apiKeySectionPath);
}
}
@@ -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<string, string?>
{
["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<SqlServerApiKeyStore>(sp.GetRequiredService<IApiKeyStore>());
Assert.IsType<SqlServerApiKeyAdminStore>(sp.GetRequiredService<IApiKeyAdminStore>());
Assert.IsType<SqlServerApiKeyAuditStore>(sp.GetRequiredService<IApiKeyAuditStore>());
Assert.IsType<SqlServerApiKeyStoreMigrator>(sp.GetRequiredService<IApiKeyStoreMigrator>());
Assert.NotNull(sp.GetRequiredService<SqliteApiKeyStoreImporter>());
Assert.NotNull(sp.GetRequiredService<IApiKeyVerifier>());
}
[Fact]
public void AddZbApiKeyAuthSqlServer_BindsSqlServerOptionsFromSection()
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["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<IOptions<SqlServerApiKeyOptions>>().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<IHostedService> hostedServices = sp.GetServices<IHostedService>();
Assert.Contains(hostedServices, s => s.GetType().Name == "ApiKeyMigrationHostedService");
}
}