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; /// Result of a one-shot import. /// Keys copied into SQL Server by this run. /// Keys whose key_id already existed in SQL Server. /// Audit rows copied into SQL Server by this run. /// Audit rows that already existed in SQL Server. /// Whether the SQLite file existed at all. public sealed record ApiKeyImportResult( int KeysInserted, int KeysSkipped, int AuditInserted, int AuditSkipped, bool SourceFound); /// /// 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. /// /// Factory for connections to the shared SQL Server API-key database. public sealed class SqliteApiKeyStoreImporter(AuthSqlServerConnectionFactory sql) { /// /// Imports the keys and audit rows held in the SQLite store at . /// /// Path of the per-node SQLite API-key file. /// A token to cancel the import. /// /// The per-category insert/skip counts. When the file does not exist the result is all zeroes /// with and nothing is /// opened — a host that has already imported (and renamed the file) pays nothing. /// /// /// /// 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 api_keys table is /// treated as an empty source rather than an error, and a pre-v3 file (no expires_utc /// column) reads its expiry as NULL = never expires, which is exactly what those keys meant. /// /// /// 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. /// /// /// A SQL Server write failed; nothing was committed. /// The source file could not be read. public async Task ImportAsync(string sqlitePath, CancellationToken ct) { ArgumentException.ThrowIfNullOrWhiteSpace(sqlitePath); if (!File.Exists(sqlitePath)) { return new ApiKeyImportResult(0, 0, 0, 0, SourceFound: false); } (IReadOnlyList keys, IReadOnlyList auditEntries) = await ReadSourceAsync(sqlitePath, ct).ConfigureAwait(false); return await WriteAsync(keys, auditEntries, ct).ConfigureAwait(false); } // --- read side (SQLite) --- private static async Task<(IReadOnlyList Keys, IReadOnlyList 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 keys = await TableExistsAsync(connection, SqliteAuthSchema.ApiKeysTable, ct).ConfigureAwait(false) ? await ReadKeysAsync(connection, ct).ConfigureAwait(false) : []; IReadOnlyList audit = await TableExistsAsync(connection, SqliteAuthSchema.ApiKeyAuditTable, ct).ConfigureAwait(false) ? await ReadAuditAsync(connection, ct).ConfigureAwait(false) : []; return (keys, audit); } private static async Task 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> 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 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(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 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> 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 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 WriteAsync( IReadOnlyList keys, IReadOnlyList 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 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 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; } }