dcee4199ab
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
301 lines
14 KiB
C#
301 lines
14 KiB
C#
using System.Data;
|
|
using Microsoft.Data.SqlClient;
|
|
using Microsoft.Data.Sqlite;
|
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
|
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
|
|
|
namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
|
|
|
|
/// <summary>Result of a one-shot import.</summary>
|
|
/// <param name="KeysInserted">Keys copied into SQL Server by this run.</param>
|
|
/// <param name="KeysSkipped">Keys whose <c>key_id</c> already existed in SQL Server.</param>
|
|
/// <param name="AuditInserted">Audit rows copied into SQL Server by this run.</param>
|
|
/// <param name="AuditSkipped">Audit rows that already existed in SQL Server.</param>
|
|
/// <param name="SourceFound">Whether the SQLite file existed at all.</param>
|
|
public sealed record ApiKeyImportResult(
|
|
int KeysInserted, int KeysSkipped, int AuditInserted, int AuditSkipped, bool SourceFound);
|
|
|
|
/// <summary>
|
|
/// Copies every key and audit row from a per-node SQLite store into the shared SQL Server store,
|
|
/// insert-where-not-exists (keys by key_id; audit rows by key_id + event_type + created_utc), so it is
|
|
/// idempotent and every node of a pair may run it: the result is the UNION of the nodes' files. A row
|
|
/// that already exists in SQL Server is never modified — a revoke recorded on one node but not the
|
|
/// other therefore survives only if the revoked copy is imported first; operators reconcile by listing.
|
|
/// </summary>
|
|
/// <param name="sql">Factory for connections to the shared SQL Server API-key database.</param>
|
|
public sealed class SqliteApiKeyStoreImporter(AuthSqlServerConnectionFactory sql)
|
|
{
|
|
/// <summary>
|
|
/// Imports the keys and audit rows held in the SQLite store at <paramref name="sqlitePath"/>.
|
|
/// </summary>
|
|
/// <param name="sqlitePath">Path of the per-node SQLite API-key file.</param>
|
|
/// <param name="ct">A token to cancel the import.</param>
|
|
/// <returns>
|
|
/// The per-category insert/skip counts. When the file does not exist the result is all zeroes
|
|
/// with <see cref="ApiKeyImportResult.SourceFound"/> <see langword="false"/> and nothing is
|
|
/// opened — a host that has already imported (and renamed the file) pays nothing.
|
|
/// </returns>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The SQLite file is opened READ-ONLY and the schema migrator is deliberately NOT run against
|
|
/// it: the source is evidence, not something to upgrade. A file with no <c>api_keys</c> table is
|
|
/// treated as an empty source rather than an error, and a pre-v3 file (no <c>expires_utc</c>
|
|
/// column) reads its expiry as NULL = never expires, which is exactly what those keys meant.
|
|
/// </para>
|
|
/// <para>
|
|
/// Every row is read and the SQLite connection closed BEFORE any SQL Server write, and all the
|
|
/// writes then run inside ONE transaction. So the import is all-or-nothing: a failure part way
|
|
/// through rolls the transaction back and the exception propagates to the caller, leaving the
|
|
/// shared store exactly as it was. The host decides what that means — for a boot-time import,
|
|
/// logging and carrying on is usually right, because keys already in SQL Server keep working.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <exception cref="SqlException">A SQL Server write failed; nothing was committed.</exception>
|
|
/// <exception cref="SqliteException">The source file could not be read.</exception>
|
|
public async Task<ApiKeyImportResult> ImportAsync(string sqlitePath, CancellationToken ct)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(sqlitePath);
|
|
|
|
if (!File.Exists(sqlitePath))
|
|
{
|
|
return new ApiKeyImportResult(0, 0, 0, 0, SourceFound: false);
|
|
}
|
|
|
|
(IReadOnlyList<ApiKeyRecord> keys, IReadOnlyList<ApiKeyAuditEntry> auditEntries) =
|
|
await ReadSourceAsync(sqlitePath, ct).ConfigureAwait(false);
|
|
|
|
return await WriteAsync(keys, auditEntries, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
// --- read side (SQLite) ---
|
|
|
|
private static async Task<(IReadOnlyList<ApiKeyRecord> Keys, IReadOnlyList<ApiKeyAuditEntry> Audit)>
|
|
ReadSourceAsync(string sqlitePath, CancellationToken ct)
|
|
{
|
|
SqliteConnectionStringBuilder builder = new()
|
|
{
|
|
DataSource = sqlitePath,
|
|
Mode = SqliteOpenMode.ReadOnly,
|
|
};
|
|
|
|
await using var connection = new SqliteConnection(builder.ToString());
|
|
await connection.OpenAsync(ct).ConfigureAwait(false);
|
|
|
|
IReadOnlyList<ApiKeyRecord> keys =
|
|
await TableExistsAsync(connection, SqliteAuthSchema.ApiKeysTable, ct).ConfigureAwait(false)
|
|
? await ReadKeysAsync(connection, ct).ConfigureAwait(false)
|
|
: [];
|
|
|
|
IReadOnlyList<ApiKeyAuditEntry> audit =
|
|
await TableExistsAsync(connection, SqliteAuthSchema.ApiKeyAuditTable, ct).ConfigureAwait(false)
|
|
? await ReadAuditAsync(connection, ct).ConfigureAwait(false)
|
|
: [];
|
|
|
|
return (keys, audit);
|
|
}
|
|
|
|
private static async Task<bool> TableExistsAsync(
|
|
SqliteConnection connection, string tableName, CancellationToken ct)
|
|
{
|
|
await using SqliteCommand command = connection.CreateCommand();
|
|
command.CommandText = """
|
|
SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = $table_name;
|
|
""";
|
|
command.Parameters.AddWithValue("$table_name", tableName);
|
|
|
|
return (long)(await command.ExecuteScalarAsync(ct).ConfigureAwait(false) ?? 0L) > 0;
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<ApiKeyRecord>> ReadKeysAsync(
|
|
SqliteConnection connection, CancellationToken ct)
|
|
{
|
|
// A pre-v3 file has no expires_utc column, so select a literal NULL in its place rather than
|
|
// failing the import: NULL is precisely what those keys mean (never expires).
|
|
bool hasExpires = await ColumnExistsAsync(
|
|
connection, SqliteAuthSchema.ApiKeysTable, SqliteAuthSchema.ExpiresUtcColumn, ct).ConfigureAwait(false);
|
|
|
|
string expiresColumn = hasExpires ? SqliteAuthSchema.ExpiresUtcColumn : "NULL";
|
|
|
|
await using SqliteCommand command = connection.CreateCommand();
|
|
command.CommandText =
|
|
"SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, " +
|
|
$"created_utc, last_used_utc, revoked_utc, {expiresColumn} FROM api_keys;";
|
|
|
|
List<ApiKeyRecord> keys = [];
|
|
|
|
await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
|
|
|
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
|
{
|
|
keys.Add(new ApiKeyRecord(
|
|
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: SqliteValueParsing.ParseUtc(reader.GetString(6)),
|
|
LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7),
|
|
RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 8),
|
|
ExpiresUtc: SqliteValueParsing.ReadNullableUtc(reader, 9)));
|
|
}
|
|
|
|
return keys;
|
|
}
|
|
|
|
// SQLite has no information_schema; PRAGMA table_info is the column probe (the same one
|
|
// SqliteAuthStoreMigrator uses to decide whether its v3 ALTER TABLE is needed).
|
|
private static async Task<bool> ColumnExistsAsync(
|
|
SqliteConnection connection, string tableName, string columnName, CancellationToken ct)
|
|
{
|
|
await using SqliteCommand command = connection.CreateCommand();
|
|
// table_info takes an identifier, not a bindable parameter; the name is an internal constant.
|
|
command.CommandText = $"PRAGMA table_info({tableName});";
|
|
|
|
await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
|
|
|
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
|
{
|
|
// PRAGMA table_info columns: cid(0), name(1), type(2), ...
|
|
if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<ApiKeyAuditEntry>> ReadAuditAsync(
|
|
SqliteConnection connection, CancellationToken ct)
|
|
{
|
|
await using SqliteCommand command = connection.CreateCommand();
|
|
// Insertion order, so the shared table's IDENTITY re-creates the source's ordering.
|
|
command.CommandText = """
|
|
SELECT key_id, event_type, remote_address, created_utc, details
|
|
FROM api_key_audit
|
|
ORDER BY audit_id;
|
|
""";
|
|
|
|
List<ApiKeyAuditEntry> entries = [];
|
|
|
|
await using SqliteDataReader 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: SqliteValueParsing.ParseUtc(reader.GetString(3)),
|
|
Details: reader.IsDBNull(4) ? null : reader.GetString(4)));
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
// --- write side (SQL Server) ---
|
|
|
|
private async Task<ApiKeyImportResult> WriteAsync(
|
|
IReadOnlyList<ApiKeyRecord> keys,
|
|
IReadOnlyList<ApiKeyAuditEntry> auditEntries,
|
|
CancellationToken ct)
|
|
{
|
|
string schema = sql.Schema;
|
|
|
|
await using SqlConnection connection = await sql.OpenConnectionAsync(ct).ConfigureAwait(false);
|
|
await using SqlTransaction transaction =
|
|
(SqlTransaction)await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
|
|
|
|
int keysInserted = 0;
|
|
int auditInserted = 0;
|
|
|
|
foreach (ApiKeyRecord key in keys)
|
|
{
|
|
if (await InsertKeyIfAbsentAsync(connection, transaction, schema, key, ct).ConfigureAwait(false))
|
|
{
|
|
keysInserted++;
|
|
}
|
|
}
|
|
|
|
foreach (ApiKeyAuditEntry entry in auditEntries)
|
|
{
|
|
if (await InsertAuditIfAbsentAsync(connection, transaction, schema, entry, ct).ConfigureAwait(false))
|
|
{
|
|
auditInserted++;
|
|
}
|
|
}
|
|
|
|
await transaction.CommitAsync(ct).ConfigureAwait(false);
|
|
|
|
return new ApiKeyImportResult(
|
|
KeysInserted: keysInserted,
|
|
KeysSkipped: keys.Count - keysInserted,
|
|
AuditInserted: auditInserted,
|
|
AuditSkipped: auditEntries.Count - auditInserted,
|
|
SourceFound: true);
|
|
}
|
|
|
|
// Returns true when this call inserted the row. The guard is inside the statement rather than a
|
|
// separate SELECT so the check and the insert cannot straddle a concurrent writer.
|
|
private async Task<bool> InsertKeyIfAbsentAsync(
|
|
SqlConnection connection,
|
|
SqlTransaction transaction,
|
|
string schema,
|
|
ApiKeyRecord key,
|
|
CancellationToken ct)
|
|
{
|
|
await using SqlCommand command = sql.CreateCommand(connection, $"""
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM [{schema}].[{SqlServerAuthSchema.ApiKeysTable}] WHERE key_id = @key_id)
|
|
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);
|
|
""", transaction);
|
|
command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = key.KeyId;
|
|
command.Parameters.Add("@key_prefix", SqlDbType.NVarChar, 16).Value = key.KeyPrefix;
|
|
command.Parameters.Add("@secret_hash", SqlDbType.VarBinary, 64).Value = key.SecretHash;
|
|
command.Parameters.Add("@display_name", SqlDbType.NVarChar, 256).Value = key.DisplayName;
|
|
command.Parameters.Add("@scopes", SqlDbType.NVarChar, -1).Value = ScopeSerializer.Serialize(key.Scopes);
|
|
command.Parameters.Add("@constraints", SqlDbType.NVarChar, -1).Value = (object?)key.ConstraintsJson ?? DBNull.Value;
|
|
command.Parameters.Add("@created_utc", SqlDbType.DateTimeOffset).Value = key.CreatedUtc;
|
|
command.Parameters.Add("@last_used_utc", SqlDbType.DateTimeOffset).Value = (object?)key.LastUsedUtc ?? DBNull.Value;
|
|
command.Parameters.Add("@revoked_utc", SqlDbType.DateTimeOffset).Value = (object?)key.RevokedUtc ?? DBNull.Value;
|
|
command.Parameters.Add("@expires_utc", SqlDbType.DateTimeOffset).Value = (object?)key.ExpiresUtc ?? DBNull.Value;
|
|
|
|
return await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false) > 0;
|
|
}
|
|
|
|
// Audit rows carry no natural key (audit_id is per-file IDENTITY), so identity is the triple
|
|
// key_id + event_type + created_utc. key_id is nullable, hence the explicit NULL-safe arm: a
|
|
// plain `key_id = @key_id` is UNKNOWN, never true, when both sides are NULL.
|
|
private async Task<bool> InsertAuditIfAbsentAsync(
|
|
SqlConnection connection,
|
|
SqlTransaction transaction,
|
|
string schema,
|
|
ApiKeyAuditEntry entry,
|
|
CancellationToken ct)
|
|
{
|
|
await using SqlCommand command = sql.CreateCommand(connection, $"""
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM [{schema}].[{SqlServerAuthSchema.ApiKeyAuditTable}]
|
|
WHERE ((key_id = @key_id) OR (key_id IS NULL AND @key_id IS NULL))
|
|
AND event_type = @event_type
|
|
AND created_utc = @created_utc)
|
|
INSERT INTO [{schema}].[{SqlServerAuthSchema.ApiKeyAuditTable}] (
|
|
key_id, event_type, remote_address, created_utc, details)
|
|
VALUES (@key_id, @event_type, @remote_address, @created_utc, @details);
|
|
""", transaction);
|
|
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;
|
|
|
|
return await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false) > 0;
|
|
}
|
|
}
|