feat(auth.apikeys.sqlserver): package skeleton, options (schema default zbauth, validated) and connection factory

Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
This commit is contained in:
Joseph Doherty
2026-09-02 14:08:22 -04:00
parent 3f11704d10
commit 2969805e4e
8 changed files with 201 additions and 0 deletions
+8
View File
@@ -15,6 +15,14 @@
<!-- Transitive security pin: Microsoft.Data.Sqlite 10.0.7 pulls SQLitePCLRaw.lib.e_sqlite3 2.1.11,
which carries advisory GHSA-2m69-gcr7-jv3q (NU1903). Pin the patched 2.1.12 native lib. -->
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
<!-- Shared SQL-Server API-key store (ZB.MOM.WW.Auth.ApiKeys.SqlServer). 6.0.2 matches the
family pin used by ZB.MOM.WW.Secrets' SQL-Server store, so a consumer taking both gets one
SqlClient rather than a downgrade warning. -->
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.0.2" />
<!-- 9.0.4, not 9.0.0: Microsoft.Data.SqlClient 6.0.2 pulls System.Configuration.ConfigurationManager
9.0.4, which requires ProtectedData >= 9.0.4. With transitive pinning on, the lower central
version is a hard NU1109 downgrade error rather than a silent resolve. -->
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="9.0.4" />
<!-- Extensions -->
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.7" />
+2
View File
@@ -2,11 +2,13 @@
<Folder Name="/src/">
<Project Path="src/ZB.MOM.WW.Auth.Abstractions/ZB.MOM.WW.Auth.Abstractions.csproj" />
<Project Path="src/ZB.MOM.WW.Auth.ApiKeys/ZB.MOM.WW.Auth.ApiKeys.csproj" />
<Project Path="src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/ZB.MOM.WW.Auth.ApiKeys.SqlServer.csproj" />
<Project Path="src/ZB.MOM.WW.Auth.AspNetCore/ZB.MOM.WW.Auth.AspNetCore.csproj" />
<Project Path="src/ZB.MOM.WW.Auth.Ldap/ZB.MOM.WW.Auth.Ldap.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ZB.MOM.WW.Auth.ApiKeys.Tests.csproj" />
<Project Path="tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.csproj" />
<Project Path="tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ZB.MOM.WW.Auth.AspNetCore.Tests.csproj" />
<Project Path="tests/ZB.MOM.WW.Auth.Ldap.Tests/ZB.MOM.WW.Auth.Ldap.Tests.csproj" />
</Folder>
@@ -0,0 +1,64 @@
using Microsoft.Data.SqlClient;
namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
/// <summary>
/// Opens connections to the shared SQL Server API-key database and carries the settings every
/// command needs (schema name, command timeout).
/// </summary>
/// <param name="options">The SQL Server API-key store options; validated in the constructor.</param>
/// <exception cref="InvalidOperationException"><paramref name="options"/> is not usable.</exception>
public sealed class AuthSqlServerConnectionFactory(SqlServerApiKeyOptions options)
{
private readonly SqlServerApiKeyOptions _options = Validate(options);
private static SqlServerApiKeyOptions Validate(SqlServerApiKeyOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
return options;
}
/// <summary>The schema that owns the three API-key tables.</summary>
public string Schema => _options.Schema;
/// <summary>Command timeout, in whole seconds, for every command issued against this store.</summary>
public int CommandTimeoutSeconds => Math.Max(1, (int)_options.CommandTimeout.TotalSeconds);
/// <summary>Opens a connection to the shared API-key database.</summary>
/// <param name="cancellationToken">A token to cancel the connect.</param>
/// <returns>An opened connection. The caller owns disposal.</returns>
public async Task<SqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{
var connection = new SqlConnection(_options.ConnectionString);
try
{
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
return connection;
}
catch
{
await connection.DisposeAsync().ConfigureAwait(false);
throw;
}
}
/// <summary>
/// Creates a command on <paramref name="connection"/> with the configured timeout applied.
/// </summary>
/// <param name="connection">The open connection.</param>
/// <param name="commandText">The command text.</param>
/// <param name="transaction">The ambient transaction, if any.</param>
/// <returns>The configured command. The caller owns disposal.</returns>
public SqlCommand CreateCommand(
SqlConnection connection, string commandText, SqlTransaction? transaction = null)
{
ArgumentNullException.ThrowIfNull(connection);
SqlCommand command = connection.CreateCommand();
command.CommandText = commandText;
command.CommandTimeout = CommandTimeoutSeconds;
command.Transaction = transaction;
return command;
}
}
@@ -0,0 +1,36 @@
namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
/// <summary>
/// Options for the shared SQL Server API-key store (section supplied by the host, e.g.
/// <c>ScadaBridge:InboundApi:ApiKeyStore:SqlServer</c>).
/// </summary>
public sealed class SqlServerApiKeyOptions
{
/// <summary>
/// Full SQL Server connection string. Required. Never a <c>${secret:}</c> reference on hosts
/// whose secret store is itself SQL-backed.
/// </summary>
public string ConnectionString { get; set; } = string.Empty;
/// <summary>Schema that owns the three tables. Letters, digits and underscore only (it is interpolated into DDL).</summary>
public string Schema { get; set; } = "zbauth";
/// <summary>
/// Command timeout applied to every store operation. Defaults to 30 seconds — long enough for
/// a contended shared database, short enough that a hung server surfaces as an error rather
/// than stalling a resolve indefinitely.
/// </summary>
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>Fails fast on a blank connection string, an unsafe schema identifier, or a non-positive command timeout.</summary>
/// <exception cref="InvalidOperationException">The options are not usable.</exception>
public void Validate()
{
if (string.IsNullOrWhiteSpace(ConnectionString))
throw new InvalidOperationException("SQL Server API-key store requires a ConnectionString.");
if (string.IsNullOrEmpty(Schema) || !Schema.All(c => char.IsAsciiLetterOrDigit(c) || c == '_') || Schema.Length > 64)
throw new InvalidOperationException($"SQL Server API-key store schema name '{Schema}' is not a safe identifier.");
if (CommandTimeout <= TimeSpan.Zero)
throw new InvalidOperationException($"SQL Server API-key store CommandTimeout must be positive (was {CommandTimeout}).");
}
}
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.Auth.ApiKeys\ZB.MOM.WW.Auth.ApiKeys.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" />
<!--
Lightweight Microsoft.Extensions.* abstractions back the DI extension
(AddZbApiKeyAuthSqlServer, shipped by Task 6): IServiceCollection / TryAdd* / Configure
(DependencyInjection.Abstractions), IOptions (Options), IConfiguration
(Configuration.Abstractions), and IHostedService / AddHostedService (Hosting.Abstractions).
-->
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<!-- Supplies the Configure<TOptions>(IConfiguration) binding overload used by the DI extension. -->
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.Auth.ApiKeys.SqlServer</PackageId>
<Authors>ZB.MOM.WW</Authors>
<Description>SQL Server API-key store for the ZB.MOM.WW SCADA family — one shared row set for clustered hosts (central pairs).</Description>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</PackageProjectUrl>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests" />
</ItemGroup>
</Project>
@@ -38,6 +38,9 @@
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.Auth.ApiKeys.Tests" />
<!-- Task 5's SqliteApiKeyStoreImporter parses SQLite values with SqliteValueParsing (internal)
rather than duplicating the parser. -->
<InternalsVisibleTo Include="ZB.MOM.WW.Auth.ApiKeys.SqlServer" />
</ItemGroup>
</Project>
@@ -0,0 +1,16 @@
namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests;
public sealed class SqlServerApiKeyOptionsTests
{
[Fact]
public void Validate_RejectsMissingConnectionString() =>
Assert.Throws<InvalidOperationException>(() => new SqlServerApiKeyOptions { ConnectionString = " " }.Validate());
[Fact]
public void Validate_RejectsUnsafeSchemaName() =>
Assert.Throws<InvalidOperationException>(() => new SqlServerApiKeyOptions { ConnectionString = "Server=x", Schema = "zb;auth" }.Validate());
[Fact]
public void Schema_DefaultsToZbauth() =>
Assert.Equal("zbauth", new SqlServerApiKeyOptions().Schema);
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<!-- Live tests (Task 3+) skip rather than fail when AUTH_SQLSERVER_CONNSTR is unset. -->
<PackageReference Include="Xunit.SkippableFact" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.ApiKeys.SqlServer\ZB.MOM.WW.Auth.ApiKeys.SqlServer.csproj" />
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.ApiKeys\ZB.MOM.WW.Auth.ApiKeys.csproj" />
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.Abstractions\ZB.MOM.WW.Auth.Abstractions.csproj" />
</ItemGroup>
</Project>