feat(auth.apikeys.sqlserver): IApiKeyStore/IApiKeyAdminStore/IApiKeyAuditStore over SQL Server, semantics pinned by the SQLite contract tests re-run live
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user