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:
Joseph Doherty
2026-09-02 14:29:58 -04:00
parent c1e34c307e
commit b5da1c8c57
6 changed files with 1340 additions and 0 deletions
@@ -0,0 +1,247 @@
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 administration store for API keys (create, revoke, rotate, delete,
/// set-scopes, enable/disable, list).
/// </summary>
/// <remarks>
/// Semantics are a deliberate one-for-one port of <c>SqliteApiKeyAdminStore</c> — same WHERE and SET
/// clauses, same rows-affected return contract — so an admin command behaves identically whichever
/// provider a host composed. The point of this provider is only WHERE the rows live: one shared row
/// set for a clustered pair instead of a file per node, so a revoke on either node is a revoke
/// everywhere.
/// </remarks>
/// <param name="connectionFactory">Factory for connections to the shared API-key database.</param>
public sealed class SqlServerApiKeyAdminStore(AuthSqlServerConnectionFactory connectionFactory) : IApiKeyAdminStore
{
/// <inheritdoc />
/// <remarks>
/// A plain INSERT: a duplicate <c>key_id</c> surfaces as a primary-key
/// <see cref="SqlException"/> rather than silently overwriting an existing key (the SQLite store
/// raises its own <c>SqliteException</c> in the same situation).
/// </remarks>
public async Task CreateAsync(ApiKeyRecord record, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(record);
string schema = connectionFactory.Schema;
await using SqlConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
INSERT INTO [{schema}].[{SqlServerAuthSchema.ApiKeysTable}] (
key_id, key_prefix, secret_hash, display_name, scopes,
constraints, created_utc, last_used_utc, revoked_utc, expires_utc)
VALUES (
@key_id, @key_prefix, @secret_hash, @display_name, @scopes,
@constraints, @created_utc, @last_used_utc, @revoked_utc, @expires_utc);
""");
command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = record.KeyId;
command.Parameters.Add("@key_prefix", SqlDbType.NVarChar, 16).Value = record.KeyPrefix;
command.Parameters.Add("@secret_hash", SqlDbType.VarBinary, 64).Value = record.SecretHash;
command.Parameters.Add("@display_name", SqlDbType.NVarChar, 256).Value = record.DisplayName;
command.Parameters.Add("@scopes", SqlDbType.NVarChar, -1).Value = ScopeSerializer.Serialize(record.Scopes);
command.Parameters.Add("@constraints", SqlDbType.NVarChar, -1).Value = (object?)record.ConstraintsJson ?? DBNull.Value;
command.Parameters.Add("@created_utc", SqlDbType.DateTimeOffset).Value = record.CreatedUtc;
command.Parameters.Add("@last_used_utc", SqlDbType.DateTimeOffset).Value = (object?)record.LastUsedUtc ?? DBNull.Value;
command.Parameters.Add("@revoked_utc", SqlDbType.DateTimeOffset).Value = (object?)record.RevokedUtc ?? DBNull.Value;
command.Parameters.Add("@expires_utc", SqlDbType.DateTimeOffset).Value = (object?)record.ExpiresUtc ?? DBNull.Value;
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
/// <inheritdoc />
/// <remarks>
/// Guarded on <c>revoked_utc IS NULL</c>, so the returned flag means "this call revoked it" —
/// a second revoke of the same key returns <see langword="false"/> and preserves the original
/// revocation instant.
/// </remarks>
public async Task<bool> RevokeAsync(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 revoked_utc = @revoked_utc
WHERE key_id = @key_id AND revoked_utc IS NULL;
""");
command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId;
command.Parameters.Add("@revoked_utc", SqlDbType.DateTimeOffset).Value = whenUtc;
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
/// <remarks>
/// Rotating also clears <c>last_used_utc</c> and <c>revoked_utc</c> — a rotated key is a fresh,
/// active credential — while leaving <c>expires_utc</c> alone, so an absolute expiry survives a
/// rotation.
/// </remarks>
public async Task<bool> RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
ArgumentNullException.ThrowIfNull(newSecretHash);
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 secret_hash = @secret_hash,
last_used_utc = NULL,
revoked_utc = NULL
WHERE key_id = @key_id;
""");
command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId;
command.Parameters.Add("@secret_hash", SqlDbType.VarBinary, 64).Value = newSecretHash;
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> SetScopesAsync(string keyId, IReadOnlySet<string> scopes, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
ArgumentNullException.ThrowIfNull(scopes);
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 scopes = @scopes
WHERE key_id = @key_id;
""");
command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId;
command.Parameters.Add("@scopes", SqlDbType.NVarChar, -1).Value = ScopeSerializer.Serialize(scopes);
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> SetEnabledAsync(string keyId, bool enabled, DateTimeOffset whenUtc, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
string schema = connectionFactory.Schema;
await using SqlConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
// Reversible toggle: NO `revoked_utc IS NULL` guard (unlike RevokeAsync), so it works
// regardless of current state. Deliberately leaves secret_hash and last_used_utc untouched
// — that is what distinguishes re-enable from RotateAsync.
SqlCommand command;
if (enabled)
{
command = connectionFactory.CreateCommand(connection, $"""
UPDATE [{schema}].[{SqlServerAuthSchema.ApiKeysTable}]
SET revoked_utc = NULL
WHERE key_id = @key_id;
""");
command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId;
}
else
{
command = connectionFactory.CreateCommand(connection, $"""
UPDATE [{schema}].[{SqlServerAuthSchema.ApiKeysTable}]
SET revoked_utc = @revoked_utc
WHERE key_id = @key_id;
""");
command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId;
command.Parameters.Add("@revoked_utc", SqlDbType.DateTimeOffset).Value = whenUtc;
}
await using (command)
{
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return rows > 0;
}
}
/// <inheritdoc />
/// <remarks>
/// Only a REVOKED key can be deleted (<c>revoked_utc IS NOT NULL</c>), so an active credential
/// cannot vanish in one step — revoke first, and the revocation stands on its own until then.
/// </remarks>
public async Task<bool> DeleteAsync(string keyId, 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, $"""
DELETE FROM [{schema}].[{SqlServerAuthSchema.ApiKeysTable}]
WHERE key_id = @key_id AND revoked_utc IS NOT NULL;
""");
command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId;
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct)
{
string schema = connectionFactory.Schema;
await using SqlConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
// Deliberately omits secret_hash so listing can never leak secret material. key_id is the
// tie-break so keys minted in the same instant still list in a stable order.
await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
SELECT key_id, key_prefix, display_name, scopes, constraints,
created_utc, last_used_utc, revoked_utc, expires_utc
FROM [{schema}].[{SqlServerAuthSchema.ApiKeysTable}]
ORDER BY created_utc DESC, key_id DESC;
""");
List<ApiKeyListItem> items = [];
await using SqlDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
items.Add(new ApiKeyListItem(
KeyId: reader.GetString(0),
KeyPrefix: reader.GetString(1),
DisplayName: reader.GetString(2),
Scopes: ScopeSerializer.Deserialize(reader.GetString(3)),
ConstraintsJson: reader.IsDBNull(4) ? null : reader.GetString(4),
CreatedUtc: reader.GetDateTimeOffset(5),
LastUsedUtc: reader.IsDBNull(6) ? null : reader.GetDateTimeOffset(6),
RevokedUtc: reader.IsDBNull(7) ? null : reader.GetDateTimeOffset(7),
ExpiresUtc: reader.IsDBNull(8) ? null : reader.GetDateTimeOffset(8)));
}
return items;
}
}
@@ -0,0 +1,80 @@
using System.Data;
using Microsoft.Data.SqlClient;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
/// <summary>
/// SQL Server-backed, append-only audit store for API-key events. A one-for-one port of
/// <c>SqliteApiKeyAuditStore</c>; on a clustered host the rows from every node land in one table,
/// so the audit trail no longer has to be stitched together per node.
/// </summary>
/// <param name="connectionFactory">Factory for connections to the shared API-key database.</param>
public sealed class SqlServerApiKeyAuditStore(AuthSqlServerConnectionFactory connectionFactory) : IApiKeyAuditStore
{
/// <inheritdoc />
public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(entry);
string schema = connectionFactory.Schema;
await using SqlConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
INSERT INTO [{schema}].[{SqlServerAuthSchema.ApiKeyAuditTable}] (
key_id, event_type, remote_address, created_utc, details)
VALUES (@key_id, @event_type, @remote_address, @created_utc, @details);
""");
command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = (object?)entry.KeyId ?? DBNull.Value;
command.Parameters.Add("@event_type", SqlDbType.NVarChar, 64).Value = entry.EventType;
command.Parameters.Add("@remote_address", SqlDbType.NVarChar, 64).Value = (object?)entry.RemoteAddress ?? DBNull.Value;
command.Parameters.Add("@created_utc", SqlDbType.DateTimeOffset).Value = entry.CreatedUtc;
command.Parameters.Add("@details", SqlDbType.NVarChar, -1).Value = (object?)entry.Details ?? DBNull.Value;
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
/// <inheritdoc />
/// <remarks>
/// Ordered by the IDENTITY <c>audit_id</c>, not by <c>created_utc</c>: two events stamped in the
/// same instant still come back in insertion order. A non-positive limit short-circuits without
/// opening a connection — <c>SELECT TOP (-1)</c> is not even legal T-SQL.
/// </remarks>
public async Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
{
if (limit <= 0)
{
return [];
}
string schema = connectionFactory.Schema;
await using SqlConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
SELECT TOP (@limit) key_id, event_type, remote_address, created_utc, details
FROM [{schema}].[{SqlServerAuthSchema.ApiKeyAuditTable}]
ORDER BY audit_id DESC;
""");
command.Parameters.Add("@limit", SqlDbType.Int).Value = limit;
List<ApiKeyAuditEntry> entries = [];
await using SqlDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
entries.Add(new ApiKeyAuditEntry(
KeyId: reader.IsDBNull(0) ? null : reader.GetString(0),
EventType: reader.GetString(1),
RemoteAddress: reader.IsDBNull(2) ? null : reader.GetString(2),
CreatedUtc: reader.GetDateTimeOffset(3),
Details: reader.IsDBNull(4) ? null : reader.GetString(4)));
}
return entries;
}
}
@@ -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));
}