diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SecretsSqliteConnectionFactory.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SecretsSqliteConnectionFactory.cs new file mode 100644 index 0000000..25998ee --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SecretsSqliteConnectionFactory.cs @@ -0,0 +1,86 @@ +using Microsoft.Data.Sqlite; + +namespace ZB.MOM.WW.Secrets.Sqlite; + +/// +/// Factory for creating and opening SQLite connections to the secret store. +/// +public sealed class SecretsSqliteConnectionFactory +{ + /// + /// Busy timeout applied to every connection. SQLite retries a busy database for + /// this long before surfacing SQLITE_BUSY, so concurrent writers degrade + /// gracefully under load instead of failing the request path. + /// + private static readonly TimeSpan BusyTimeout = TimeSpan.FromSeconds(5); + + private readonly string _sqlitePath; + + /// Creates a factory targeting the database at . + /// Filesystem path of the SQLite database file. + public SecretsSqliteConnectionFactory(string sqlitePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sqlitePath); + _sqlitePath = sqlitePath; + } + + /// + /// Creates an unopened SQLite connection (Mode=ReadWriteCreate). Prefer + /// , which also applies WAL journaling and the + /// busy timeout. + /// + public SqliteConnection CreateConnection() + { + string? directory = Path.GetDirectoryName(_sqlitePath); + + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + SqliteConnectionStringBuilder builder = new() + { + DataSource = _sqlitePath, + Mode = SqliteOpenMode.ReadWriteCreate, + Pooling = true, + DefaultTimeout = (int)BusyTimeout.TotalSeconds, + }; + + return new SqliteConnection(builder.ToString()); + } + + /// + /// Creates a SQLite connection, opens it, and configures WAL journaling and a + /// non-zero busy timeout so concurrent readers and writers degrade gracefully + /// rather than surfacing SQLITE_BUSY as a hard failure. + /// + /// Cancellation token for the operation. + /// An opened and configured SQLite connection. + public async Task OpenConnectionAsync(CancellationToken cancellationToken) + { + SqliteConnection connection = CreateConnection(); + try + { + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await ConfigureConnectionAsync(connection, cancellationToken).ConfigureAwait(false); + return connection; + } + catch + { + await connection.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + private static async Task ConfigureConnectionAsync( + SqliteConnection connection, + CancellationToken cancellationToken) + { + // WAL is a persistent, database-level setting; re-applying it per connection + // is cheap and a no-op once set. busy_timeout is per-connection state. + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = + $"PRAGMA journal_mode=WAL; PRAGMA busy_timeout={(int)BusyTimeout.TotalMilliseconds};"; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsSchema.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsSchema.cs new file mode 100644 index 0000000..4443c57 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsSchema.cs @@ -0,0 +1,59 @@ +namespace ZB.MOM.WW.Secrets.Sqlite; + +/// +/// Schema constants and table DDL for the secret SQLite store. +/// +public static class SqliteSecretsSchema +{ + /// + /// The schema version this build creates and supports. This is the first version, so all DDL is a + /// single-shot CREATE ... IF NOT EXISTS. The migrator refuses an on-disk version + /// newer than this. Future additive columns (nullable ALTER TABLEs, gated by a + /// PRAGMA table_info existence probe) would bump this and register a forward migration. + /// + public const int CurrentVersion = 1; + + /// Name of the single-row table tracking the applied schema version. + public const string SchemaVersionTable = "schema_version"; + + /// Name of the table storing envelope-encrypted secret records. + public const string SecretTable = "secret"; + + /// DDL creating the single-row schema-version table. + public const string CreateSchemaVersionTable = """ + CREATE TABLE IF NOT EXISTS schema_version ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL, + applied_utc TEXT NOT NULL + ); + """; + + /// DDL creating the envelope-encrypted secret record table. + public const string CreateSecretTable = """ + CREATE TABLE IF NOT EXISTS secret ( + name TEXT PRIMARY KEY, + description TEXT NULL, + content_type TEXT NOT NULL, + ciphertext BLOB NOT NULL, + nonce BLOB NOT NULL, + tag BLOB NOT NULL, + wrapped_dek BLOB NOT NULL, + wrap_nonce BLOB NOT NULL, + wrap_tag BLOB NOT NULL, + kek_id TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 0, + is_deleted INTEGER NOT NULL DEFAULT 0, + deleted_utc TEXT NULL, + created_utc TEXT NOT NULL, + updated_utc TEXT NOT NULL, + created_by TEXT NULL, + updated_by TEXT NULL + ); + """; + + /// DDL creating supporting indexes (idempotent). + public const string CreateIndexes = """ + CREATE INDEX IF NOT EXISTS ix_secret_updated_utc + ON secret (updated_utc); + """; +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsStoreMigrator.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsStoreMigrator.cs new file mode 100644 index 0000000..1198f3e --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsStoreMigrator.cs @@ -0,0 +1,138 @@ +using System.Globalization; +using Microsoft.Data.Sqlite; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.Sqlite; + +/// +/// Creates the secret store schema and records the applied version. Idempotent: it is +/// safe to run repeatedly. Refuses to run against a database whose on-disk version is +/// newer than this build supports. +/// +public sealed class SqliteSecretsStoreMigrator(SecretsSqliteConnectionFactory connectionFactory) +{ + /// Applies the schema migration to the secret store. + /// Cancellation token. + /// + /// The on-disk schema version is newer than . + /// + public async Task MigrateAsync(CancellationToken cancellationToken) + { + await using SqliteConnection connection = + await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + + await using SqliteTransaction transaction = + (SqliteTransaction)await connection.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken).ConfigureAwait(false); + + int existingVersion = + await ReadExistingSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false); + + if (existingVersion > SqliteSecretsSchema.CurrentVersion) + { + throw new SecretStoreMigrationException( + $"Secret database schema version {existingVersion} is newer than supported version {SqliteSecretsSchema.CurrentVersion}."); + } + + await ApplySchemaAsync(connection, transaction, cancellationToken).ConfigureAwait(false); + await WriteSchemaVersionAsync(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", SqliteSecretsSchema.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, CultureInfo.InvariantCulture); + } + + // Single-shot create of the final schema (all DDL is CREATE ... IF NOT EXISTS, so it is idempotent + // against an already-provisioned database). The applied version is stamped separately by + // WriteSchemaVersionAsync. + // + // Forward-migration hook: when CurrentVersion advances past 1, any additive column added to the + // secret table must ALSO be applied here to pre-existing databases via an idempotent + // "ALTER TABLE secret ADD COLUMN ..." guarded by a `PRAGMA table_info(secret)` column-existence + // probe (SQLite has no "ADD COLUMN IF NOT EXISTS"). Fully consume and dispose the PRAGMA reader + // before issuing the ALTER on the same connection/transaction. See the sibling Auth migrator + // (SqliteAuthStoreMigrator.EnsureApiKeysColumnAsync) for the proven pattern. Schema v1 has no + // additive ALTERs, so the CREATE above is the whole migration. + private static async Task ApplySchemaAsync( + SqliteConnection connection, + SqliteTransaction transaction, + CancellationToken cancellationToken) + { + await ExecuteNonQueryAsync( + connection, + transaction, + string.Join( + "\n", + SqliteSecretsSchema.CreateSchemaVersionTable, + SqliteSecretsSchema.CreateSecretTable, + SqliteSecretsSchema.CreateIndexes), + cancellationToken).ConfigureAwait(false); + } + + private static async Task WriteSchemaVersionAsync( + SqliteConnection connection, + SqliteTransaction transaction, + CancellationToken cancellationToken) + { + 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", SqliteSecretsSchema.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); + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreMigratorTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreMigratorTests.cs new file mode 100644 index 0000000..c11672f --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreMigratorTests.cs @@ -0,0 +1,127 @@ +using Microsoft.Data.Sqlite; +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.Sqlite; + +namespace ZB.MOM.WW.Secrets.Tests.Sqlite; + +public sealed class SqliteSecretsStoreMigratorTests : IDisposable +{ + private readonly string _dbPath = + Path.Combine(Path.GetTempPath(), $"zb-secrets-migrator-{Guid.NewGuid():N}.db"); + + [Fact] + public async Task Migrate_IsIdempotent() + { + var factory = new SecretsSqliteConnectionFactory(_dbPath); + var migrator = new SqliteSecretsStoreMigrator(factory); + + // Running twice must not throw and must leave the schema version at CurrentVersion. + await migrator.MigrateAsync(CancellationToken.None); + await migrator.MigrateAsync(CancellationToken.None); + + long version = await ReadSchemaVersionAsync(factory); + Assert.Equal(SqliteSecretsSchema.CurrentVersion, version); + Assert.Equal(1, version); + } + + [Fact] + public async Task Migrate_CreatesSecretTableWithExpectedColumns() + { + var factory = new SecretsSqliteConnectionFactory(_dbPath); + var migrator = new SqliteSecretsStoreMigrator(factory); + + await migrator.MigrateAsync(CancellationToken.None); + + HashSet columns = await ReadSecretColumnsAsync(factory); + + string[] expected = + [ + "name", "description", "content_type", "ciphertext", "nonce", "tag", + "wrapped_dek", "wrap_nonce", "wrap_tag", "kek_id", "revision", + "is_deleted", "deleted_utc", "created_utc", "updated_utc", + "created_by", "updated_by", + ]; + + Assert.Equal(17, expected.Length); + Assert.Equal(17, columns.Count); + foreach (string column in expected) + { + Assert.Contains(column, columns); + } + } + + [Fact] + public async Task Migrate_RefusesNewerSchema() + { + var factory = new SecretsSqliteConnectionFactory(_dbPath); + + // Manually stamp an on-disk version newer than this build supports. + await using (SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None)) + { + await using SqliteCommand create = connection.CreateCommand(); + create.CommandText = SqliteSecretsSchema.CreateSchemaVersionTable; + await create.ExecuteNonQueryAsync(); + + await using SqliteCommand insert = connection.CreateCommand(); + insert.CommandText = """ + INSERT INTO schema_version (id, version, applied_utc) + VALUES (1, $version, $applied_utc); + """; + insert.Parameters.AddWithValue("$version", 999); + insert.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O")); + await insert.ExecuteNonQueryAsync(); + } + + var migrator = new SqliteSecretsStoreMigrator(factory); + + await Assert.ThrowsAsync( + () => migrator.MigrateAsync(CancellationToken.None)); + } + + private static async Task ReadSchemaVersionAsync(SecretsSqliteConnectionFactory factory) + { + await using SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None); + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = "SELECT version FROM schema_version WHERE id = 1;"; + object? result = await command.ExecuteScalarAsync(); + return Convert.ToInt64(result); + } + + private static async Task> ReadSecretColumnsAsync(SecretsSqliteConnectionFactory factory) + { + await using SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None); + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = $"PRAGMA table_info({SqliteSecretsSchema.SecretTable});"; + + var columns = new HashSet(StringComparer.Ordinal); + await using SqliteDataReader reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + // PRAGMA table_info columns: cid(0), name(1), type(2), ... + columns.Add(reader.GetString(1)); + } + + return columns; + } + + public void Dispose() + { + // Drop pooled connections so the WAL/-shm/-wal sidecars release before we delete. + SqliteConnection.ClearAllPools(); + + foreach (string path in new[] { _dbPath, _dbPath + "-wal", _dbPath + "-shm" }) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (IOException) + { + // Best-effort temp cleanup; a leaked temp file is not a test failure. + } + } + } +}