feat(secrets): sqlite connection factory + schema v1 + migrator

This commit is contained in:
Joseph Doherty
2026-07-15 16:46:15 -04:00
parent 1c13b4af99
commit e99a8731e9
4 changed files with 410 additions and 0 deletions
@@ -0,0 +1,86 @@
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.Secrets.Sqlite;
/// <summary>
/// Factory for creating and opening SQLite connections to the secret store.
/// </summary>
public sealed class SecretsSqliteConnectionFactory
{
/// <summary>
/// Busy timeout applied to every connection. SQLite retries a busy database for
/// this long before surfacing <c>SQLITE_BUSY</c>, so concurrent writers degrade
/// gracefully under load instead of failing the request path.
/// </summary>
private static readonly TimeSpan BusyTimeout = TimeSpan.FromSeconds(5);
private readonly string _sqlitePath;
/// <summary>Creates a factory targeting the database at <paramref name="sqlitePath"/>.</summary>
/// <param name="sqlitePath">Filesystem path of the SQLite database file.</param>
public SecretsSqliteConnectionFactory(string sqlitePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sqlitePath);
_sqlitePath = sqlitePath;
}
/// <summary>
/// Creates an unopened SQLite connection (Mode=ReadWriteCreate). Prefer
/// <see cref="OpenConnectionAsync"/>, which also applies WAL journaling and the
/// busy timeout.
/// </summary>
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());
}
/// <summary>
/// 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 <c>SQLITE_BUSY</c> as a hard failure.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>An opened and configured SQLite connection.</returns>
public async Task<SqliteConnection> 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);
}
}
@@ -0,0 +1,59 @@
namespace ZB.MOM.WW.Secrets.Sqlite;
/// <summary>
/// Schema constants and table DDL for the secret SQLite store.
/// </summary>
public static class SqliteSecretsSchema
{
/// <summary>
/// The schema version this build creates and supports. This is the first version, so all DDL is a
/// single-shot <c>CREATE ... IF NOT EXISTS</c>. The migrator refuses an on-disk version
/// <em>newer</em> than this. Future additive columns (nullable <c>ALTER TABLE</c>s, gated by a
/// <c>PRAGMA table_info</c> existence probe) would bump this and register a forward migration.
/// </summary>
public const int CurrentVersion = 1;
/// <summary>Name of the single-row table tracking the applied schema version.</summary>
public const string SchemaVersionTable = "schema_version";
/// <summary>Name of the table storing envelope-encrypted secret records.</summary>
public const string SecretTable = "secret";
/// <summary>DDL creating the single-row schema-version table.</summary>
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
);
""";
/// <summary>DDL creating the envelope-encrypted secret record table.</summary>
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
);
""";
/// <summary>DDL creating supporting indexes (idempotent).</summary>
public const string CreateIndexes = """
CREATE INDEX IF NOT EXISTS ix_secret_updated_utc
ON secret (updated_utc);
""";
}
@@ -0,0 +1,138 @@
using System.Globalization;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Sqlite;
/// <summary>
/// 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.
/// </summary>
public sealed class SqliteSecretsStoreMigrator(SecretsSqliteConnectionFactory connectionFactory)
{
/// <summary>Applies the schema migration to the secret store.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <exception cref="SecretStoreMigrationException">
/// The on-disk schema version is newer than <see cref="SqliteSecretsSchema.CurrentVersion"/>.
/// </exception>
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<int> 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);
}
}