Files
scadaproj/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyStore.cs
T

101 lines
4.4 KiB
C#

using System.Data;
using Microsoft.Data.SqlClient;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
/// <summary>
/// SQL Server-backed read store for API-key records — the verification hot path.
/// </summary>
/// <remarks>
/// Semantics are a deliberate one-for-one port of <c>SqliteApiKeyStore</c>, down to the WHERE
/// clauses, so a host can switch providers without any behaviour changing. One connection per
/// operation: the shared store is reached over the network and pooling, not a held connection, is
/// what keeps a central pair's concurrent verifies cheap.
/// </remarks>
/// <param name="connectionFactory">Factory for connections to the shared API-key database.</param>
public sealed class SqlServerApiKeyStore(AuthSqlServerConnectionFactory connectionFactory) : IApiKeyStore
{
private const string SelectColumns =
"key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc, expires_utc";
/// <inheritdoc />
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
return FindAsync(keyId, requireActive: false, ct);
}
/// <inheritdoc />
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
return FindAsync(keyId, requireActive: true, ct);
}
/// <inheritdoc />
/// <remarks>
/// Guarded on <c>revoked_utc IS NULL</c>: a revoked key's last-used stamp is frozen at its last
/// legitimate use, so it never records the rejected attempts that follow.
/// </remarks>
public async Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
string schema = connectionFactory.Schema;
await using SqlConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
UPDATE [{schema}].[{SqlServerAuthSchema.ApiKeysTable}]
SET last_used_utc = @last_used_utc
WHERE key_id = @key_id AND revoked_utc IS NULL;
""");
command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId;
command.Parameters.Add("@last_used_utc", SqlDbType.DateTimeOffset).Value = whenUtc;
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
private async Task<ApiKeyRecord?> FindAsync(string keyId, bool requireActive, CancellationToken ct)
{
string schema = connectionFactory.Schema;
string table = $"[{schema}].[{SqlServerAuthSchema.ApiKeysTable}]";
await using SqlConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
// Active means NOT REVOKED only. Expiry is deliberately not filtered here — the verifier
// owns it, so it can tell an expired key from an unknown one (ApiKeyFailure.KeyExpired).
await using SqlCommand command = connectionFactory.CreateCommand(
connection,
requireActive
? $"SELECT {SelectColumns} FROM {table} WHERE key_id = @key_id AND revoked_utc IS NULL;"
: $"SELECT {SelectColumns} FROM {table} WHERE key_id = @key_id;");
command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId;
await using SqlDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (!await reader.ReadAsync(ct).ConfigureAwait(false))
{
return null;
}
return ReadRecord(reader);
}
internal static ApiKeyRecord ReadRecord(SqlDataReader reader) => new(
KeyId: reader.GetString(0),
KeyPrefix: reader.GetString(1),
SecretHash: reader.GetFieldValue<byte[]>(2),
DisplayName: reader.GetString(3),
Scopes: ScopeSerializer.Deserialize(reader.GetString(4)),
ConstraintsJson: reader.IsDBNull(5) ? null : reader.GetString(5),
CreatedUtc: reader.GetDateTimeOffset(6),
LastUsedUtc: reader.IsDBNull(7) ? null : reader.GetDateTimeOffset(7),
RevokedUtc: reader.IsDBNull(8) ? null : reader.GetDateTimeOffset(8),
ExpiresUtc: reader.IsDBNull(9) ? null : reader.GetDateTimeOffset(9));
}