diff --git a/ZB.MOM.WW.Auth/Directory.Packages.props b/ZB.MOM.WW.Auth/Directory.Packages.props index 1e7fff5..3b06c64 100644 --- a/ZB.MOM.WW.Auth/Directory.Packages.props +++ b/ZB.MOM.WW.Auth/Directory.Packages.props @@ -15,6 +15,14 @@ + + + + diff --git a/ZB.MOM.WW.Auth/ZB.MOM.WW.Auth.slnx b/ZB.MOM.WW.Auth/ZB.MOM.WW.Auth.slnx index e5f051b..5cb6064 100644 --- a/ZB.MOM.WW.Auth/ZB.MOM.WW.Auth.slnx +++ b/ZB.MOM.WW.Auth/ZB.MOM.WW.Auth.slnx @@ -2,11 +2,13 @@ + + diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/AuthSqlServerConnectionFactory.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/AuthSqlServerConnectionFactory.cs new file mode 100644 index 0000000..c0cdca5 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/AuthSqlServerConnectionFactory.cs @@ -0,0 +1,64 @@ +using Microsoft.Data.SqlClient; + +namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer; + +/// +/// Opens connections to the shared SQL Server API-key database and carries the settings every +/// command needs (schema name, command timeout). +/// +/// The SQL Server API-key store options; validated in the constructor. +/// is not usable. +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; + } + + /// The schema that owns the three API-key tables. + public string Schema => _options.Schema; + + /// Command timeout, in whole seconds, for every command issued against this store. + public int CommandTimeoutSeconds => Math.Max(1, (int)_options.CommandTimeout.TotalSeconds); + + /// Opens a connection to the shared API-key database. + /// A token to cancel the connect. + /// An opened connection. The caller owns disposal. + public async Task 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; + } + } + + /// + /// Creates a command on with the configured timeout applied. + /// + /// The open connection. + /// The command text. + /// The ambient transaction, if any. + /// The configured command. The caller owns disposal. + 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; + } +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyOptions.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyOptions.cs new file mode 100644 index 0000000..a3b4006 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyOptions.cs @@ -0,0 +1,36 @@ +namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer; + +/// +/// Options for the shared SQL Server API-key store (section supplied by the host, e.g. +/// ScadaBridge:InboundApi:ApiKeyStore:SqlServer). +/// +public sealed class SqlServerApiKeyOptions +{ + /// + /// Full SQL Server connection string. Required. Never a ${secret:} reference on hosts + /// whose secret store is itself SQL-backed. + /// + public string ConnectionString { get; set; } = string.Empty; + + /// Schema that owns the three tables. Letters, digits and underscore only (it is interpolated into DDL). + public string Schema { get; set; } = "zbauth"; + + /// + /// 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. + /// + public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// Fails fast on a blank connection string, an unsafe schema identifier, or a non-positive command timeout. + /// The options are not usable. + 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})."); + } +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/ZB.MOM.WW.Auth.ApiKeys.SqlServer.csproj b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/ZB.MOM.WW.Auth.ApiKeys.SqlServer.csproj new file mode 100644 index 0000000..f073efb --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/ZB.MOM.WW.Auth.ApiKeys.SqlServer.csproj @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + net10.0 + enable + enable + + + + true + ZB.MOM.WW.Auth.ApiKeys.SqlServer + ZB.MOM.WW + SQL Server API-key store for the ZB.MOM.WW SCADA family — one shared row set for clustered hosts (central pairs). + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth + + + + + + + diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ZB.MOM.WW.Auth.ApiKeys.csproj b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ZB.MOM.WW.Auth.ApiKeys.csproj index 72aeab0..be93eb2 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ZB.MOM.WW.Auth.ApiKeys.csproj +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ZB.MOM.WW.Auth.ApiKeys.csproj @@ -38,6 +38,9 @@ + + diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/SqlServerApiKeyOptionsTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/SqlServerApiKeyOptionsTests.cs new file mode 100644 index 0000000..568e627 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/SqlServerApiKeyOptionsTests.cs @@ -0,0 +1,16 @@ +namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests; + +public sealed class SqlServerApiKeyOptionsTests +{ + [Fact] + public void Validate_RejectsMissingConnectionString() => + Assert.Throws(() => new SqlServerApiKeyOptions { ConnectionString = " " }.Validate()); + + [Fact] + public void Validate_RejectsUnsafeSchemaName() => + Assert.Throws(() => new SqlServerApiKeyOptions { ConnectionString = "Server=x", Schema = "zb;auth" }.Validate()); + + [Fact] + public void Schema_DefaultsToZbauth() => + Assert.Equal("zbauth", new SqlServerApiKeyOptions().Schema); +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.csproj b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.csproj new file mode 100644 index 0000000..9e9e6ae --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.csproj @@ -0,0 +1,30 @@ + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + +