using Microsoft.Data.Sqlite; namespace MxGateway.Server.Security.Authentication; public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory) : IAuthStoreMigrator { public async Task MigrateAsync(CancellationToken cancellationToken) { await using SqliteConnection connection = connectionFactory.CreateConnection(); await connection.OpenAsync(cancellationToken).ConfigureAwait(false); await using SqliteTransaction transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); int existingVersion = await ReadExistingSchemaVersionAsync(connection, transaction, cancellationToken) .ConfigureAwait(false); if (existingVersion > SqliteAuthSchema.CurrentVersion) { throw new AuthStoreMigrationException( $"Auth database schema version {existingVersion} is newer than supported version {SqliteAuthSchema.CurrentVersion}."); } await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); } private static async Task ReadExistingSchemaVersionAsync( SqliteConnection connection, SqliteTransaction transaction, CancellationToken cancellationToken) { await using SqliteCommand tableExistsCommand = connection.CreateCommand(); tableExistsCommand.Transaction = transaction; tableExistsCommand.CommandText = """ SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = $table_name; """; tableExistsCommand.Parameters.AddWithValue("$table_name", SqliteAuthSchema.SchemaVersionTable); long tableCount = (long)(await tableExistsCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) ?? 0L); if (tableCount == 0) { return 0; } await using SqliteCommand versionCommand = connection.CreateCommand(); versionCommand.Transaction = transaction; versionCommand.CommandText = """ SELECT version FROM schema_version WHERE id = 1; """; object? version = await versionCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); return version is null || version == DBNull.Value ? 0 : Convert.ToInt32(version, System.Globalization.CultureInfo.InvariantCulture); } private static async Task ApplyVersionOneAsync( SqliteConnection connection, SqliteTransaction transaction, CancellationToken cancellationToken) { await ExecuteNonQueryAsync( connection, transaction, """ CREATE TABLE IF NOT EXISTS schema_version ( id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL, applied_utc TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS api_keys ( key_id TEXT PRIMARY KEY, key_prefix TEXT NOT NULL, secret_hash BLOB NOT NULL, display_name TEXT NOT NULL, scopes TEXT NOT NULL, created_utc TEXT NOT NULL, last_used_utc TEXT NULL, revoked_utc TEXT NULL ); CREATE TABLE IF NOT EXISTS api_key_audit ( audit_id INTEGER PRIMARY KEY AUTOINCREMENT, key_id TEXT NULL, event_type TEXT NOT NULL, remote_address TEXT NULL, created_utc TEXT NOT NULL, details TEXT NULL ); CREATE INDEX IF NOT EXISTS ix_api_keys_revoked_utc ON api_keys (revoked_utc); CREATE INDEX IF NOT EXISTS ix_api_key_audit_key_id_created_utc ON api_key_audit (key_id, created_utc); """, cancellationToken).ConfigureAwait(false); await using SqliteCommand versionCommand = connection.CreateCommand(); versionCommand.Transaction = transaction; versionCommand.CommandText = """ INSERT INTO schema_version (id, version, applied_utc) VALUES (1, $version, $applied_utc) ON CONFLICT(id) DO UPDATE SET version = excluded.version, applied_utc = excluded.applied_utc; """; versionCommand.Parameters.AddWithValue("$version", SqliteAuthSchema.CurrentVersion); versionCommand.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O")); await versionCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } private static async Task ExecuteNonQueryAsync( SqliteConnection connection, SqliteTransaction transaction, string commandText, CancellationToken cancellationToken) { await using SqliteCommand command = connection.CreateCommand(); command.Transaction = transaction; command.CommandText = commandText; await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } }