feat(secrets): sqlite connection factory + schema v1 + migrator
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
@@ -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<string> 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<SecretStoreMigrationException>(
|
||||||
|
() => migrator.MigrateAsync(CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<long> 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<HashSet<string>> 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<string>(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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user