diff options
| author | Leitwolf11 | 2026-10-06 22:53:46 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-10-06 22:53:46 +0300 |
| commit | 1c986ff99b3c8f349e4a63c0e922276ddf7dc836 (patch) | |
| tree | 33c88e9cbeaa7758365a62c4c12ca9726dad607d /apps/api | |
| parent | 933244cc0fa149427d22d06be4d226c0a67e8b80 (diff) | |
| download | progmaweb-1c986ff99b3c8f349e4a63c0e922276ddf7dc836.tar.gz | |
Keep accounts and sessions in PostgreSQL
The account API kept everything in the memory of its process, so every
restart and every deployment of the API deleted all accounts and signed
everyone out.
PostgresAccountStore implements IAccountStore on PostgreSQL through Npgsql.
The three uniqueness rules are unique constraints, so two requests that race
for one name, address or Google identity are decided by the database. A
refused account is an ordinary answer, not an exception, and the rules are
reported in the order the in-memory store reports them. A session refers to
its account, and a session of an account that is not stored is refused; the
in-memory store now refuses it as well.
The schema is a list of numbered steps. The service takes the missing ones
when it starts, in one transaction under an advisory lock, and does not
start when the database cannot be reached or is newer than the release.
/health answers 503 while the database does not answer.
ConnectionStrings:Accounts selects PostgreSQL. A production service that is
given no database does not start; keeping accounts in memory there has to
be asked for with Accounts:Store=Memory. Development and the tests keep the
in-memory store by default.
The store gains RemoveExpiredSessionsAsync, and a background service calls
it at the start and once an hour: a session whose token is never presented
again no longer keeps its row for ever.
PostgreSQL keeps a timestamp to the microsecond. The store cuts a moment to
that precision and returns what it stored, and the service uses the
returned account, so the creation time in the registration response equals
the one every later read returns.
Tests. AccountStoreContractTests states the rules once and runs them on
both stores. Further tests cover the schema steps applied concurrently and a
database that is too new, the choice of the store from the configuration,
the removal of expired sessions, and an account with its session surviving
a restart of the service over HTTP. The PostgreSQL tests read their
database from PROGMAWEB_TEST_POSTGRES and work in a schema of their own
each. Without the variable they are skipped; CI starts PostgreSQL 16 for
them, and a test fails the run when the variable is missing there.
Operations. ops/README.md describes the database: the Unix socket with peer
authentication, so that no password exists, the one-time preparation, and
rollback across a schema step. The service unit starts after PostgreSQL.
docs/LIMITATIONS.md no longer lists the in-memory store and now lists the
missing backup and the missing deletion of accounts.
Diffstat (limited to 'apps/api')
| -rw-r--r-- | apps/api/Accounts/AccountSchema.cs | 133 | ||||
| -rw-r--r-- | apps/api/Accounts/AccountService.cs | 5 | ||||
| -rw-r--r-- | apps/api/Accounts/AccountStoreRegistration.cs | 253 | ||||
| -rw-r--r-- | apps/api/Accounts/IAccountStore.cs | 9 | ||||
| -rw-r--r-- | apps/api/Accounts/InMemoryAccountStore.cs | 28 | ||||
| -rw-r--r-- | apps/api/Accounts/PostgresAccountStore.cs | 200 | ||||
| -rw-r--r-- | apps/api/Progmaweb.Api.csproj | 1 | ||||
| -rw-r--r-- | apps/api/Program.cs | 9 | ||||
| -rw-r--r-- | apps/api/packages.lock.json | 6 |
9 files changed, 636 insertions, 8 deletions
diff --git a/apps/api/Accounts/AccountSchema.cs b/apps/api/Accounts/AccountSchema.cs new file mode 100644 index 0000000..3fc3707 --- /dev/null +++ b/apps/api/Accounts/AccountSchema.cs @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> +// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 + +using Npgsql; + +namespace Progmasoft.Progmaweb.Api.Accounts; + +/// <summary>The tables of <see cref="PostgresAccountStore"/> and the steps that bring a database to them.</summary> +/// <remarks> +/// <para> +/// The schema is a list of numbered steps. A database records the steps it has taken in +/// <c>account_schema_versions</c>, and <see cref="ApplyAsync"/> takes the ones that are missing, in order, in one +/// transaction. A step is never edited after it has been released; a change to the tables is a new step. +/// </para> +/// <para> +/// The tables are created in the schema the connection selects, without a schema name in the statements, so the +/// connection string decides where they live. +/// </para> +/// </remarks> +internal static class AccountSchema +{ + /// <summary>Key of the advisory lock that lets one process at a time change the schema.</summary> + /// <remarks>The value is arbitrary and fixed; it only has to differ from the keys of other applications.</remarks> + private const long MigrationLockKey = 0x50726F676D6157; + + /// <summary>The steps, in order. The version of a step is its position, counted from one.</summary> + private static readonly string[] Steps = + [ + // 1: accounts and sessions. + // + // The three uniqueness rules of the store are the unique constraints below. A unique constraint ignores + // rows whose column is null, which is the rule for google_subject: many password accounts, each without a + // Google identity. A session is removed with its account, and the index on expires_at is what the removal + // of expired sessions scans. + """ + CREATE TABLE accounts ( + id uuid PRIMARY KEY, + account_name text NOT NULL, + normalized_account_name text NOT NULL CONSTRAINT accounts_normalized_account_name_key UNIQUE, + email text NOT NULL, + normalized_email text NOT NULL CONSTRAINT accounts_normalized_email_key UNIQUE, + google_subject text CONSTRAINT accounts_google_subject_key UNIQUE, + password_hash text, + created_at timestamptz NOT NULL + ); + + CREATE TABLE sessions ( + token_digest bytea PRIMARY KEY, + account_id uuid NOT NULL REFERENCES accounts (id) ON DELETE CASCADE, + created_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL + ); + + CREATE INDEX sessions_expires_at_idx ON sessions (expires_at); + CREATE INDEX sessions_account_id_idx ON sessions (account_id); + """ + ]; + + /// <summary>The version a database has after every step.</summary> + public static int CurrentVersion => Steps.Length; + + /// <summary>Takes the steps a database has not taken yet.</summary> + /// <remarks> + /// Processes that start together wait for one another on an advisory lock, so each step is taken once. A + /// database that is newer than this program is refused: its tables may no longer be what the store expects. + /// </remarks> + /// <param name="dataSource">Connections to the database.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The number of steps that were taken; zero when the database was current.</returns> + /// <exception cref="InvalidOperationException">The database has taken steps this program does not know.</exception> + public static async Task<int> ApplyAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken) + { + await using NpgsqlConnection connection = await dataSource.OpenConnectionAsync(cancellationToken); + await using NpgsqlTransaction transaction = await connection.BeginTransactionAsync(cancellationToken); + + // The lock is released with the transaction, also when a step fails. + await ExecuteAsync(connection, transaction, $"SELECT pg_advisory_xact_lock({MigrationLockKey})", cancellationToken); + await ExecuteAsync( + connection, + transaction, + """ + CREATE TABLE IF NOT EXISTS account_schema_versions ( + version integer PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + ) + """, + cancellationToken); + + int version; + await using (NpgsqlCommand read = new( + "SELECT coalesce(max(version), 0) FROM account_schema_versions", connection, transaction)) + { + version = (int)(await read.ExecuteScalarAsync(cancellationToken))!; + } + + if (version > Steps.Length) + { + throw new InvalidOperationException( + $"The account database is at schema version {version}, and this program knows version {Steps.Length}. " + + "Run the release that created the database, or a newer one."); + } + + int taken = 0; + for (int step = version; step < Steps.Length; step++) + { + await ExecuteAsync(connection, transaction, Steps[step], cancellationToken); + await using NpgsqlCommand record = new( + "INSERT INTO account_schema_versions (version) VALUES ($1)", connection, transaction); + record.Parameters.AddWithValue(step + 1); + await record.ExecuteNonQueryAsync(cancellationToken); + taken++; + } + + await transaction.CommitAsync(cancellationToken); + return taken; + } + + /// <summary>Runs statements that return nothing.</summary> + /// <param name="connection">The connection to run them on.</param> + /// <param name="transaction">The transaction they belong to.</param> + /// <param name="sql">The statements.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>A task that completes when the statements have run.</returns> + private static async Task ExecuteAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + string sql, + CancellationToken cancellationToken) + { + await using NpgsqlCommand command = new(sql, connection, transaction); + await command.ExecuteNonQueryAsync(cancellationToken); + } +} diff --git a/apps/api/Accounts/AccountService.cs b/apps/api/Accounts/AccountService.cs index cd3e711..7634948 100644 --- a/apps/api/Accounts/AccountService.cs +++ b/apps/api/Accounts/AccountService.cs @@ -76,7 +76,8 @@ internal sealed class AccountService( return (null, new Dictionary<string, string[]> { ["email"] = ["That email address is already registered."] }); } - AuthenticatedAccount authentication = await sessions.CreateAsync(account, cancellationToken); + // The store returns the account as it keeps it, which is what every later read returns. + AuthenticatedAccount authentication = await sessions.CreateAsync(result.Account ?? account, cancellationToken); return (authentication, errors); } @@ -200,7 +201,7 @@ internal sealed class AccountService( : (await sessions.CreateAsync(concurrent, cancellationToken), GoogleAccountFailure.None); } - return (await sessions.CreateAsync(account, cancellationToken), GoogleAccountFailure.None); + return (await sessions.CreateAsync(result.Account ?? account, cancellationToken), GoogleAccountFailure.None); } /// <summary>Validates every field of a registration and collects the messages of the refused ones.</summary> diff --git a/apps/api/Accounts/AccountStoreRegistration.cs b/apps/api/Accounts/AccountStoreRegistration.cs new file mode 100644 index 0000000..14a77a6 --- /dev/null +++ b/apps/api/Accounts/AccountStoreRegistration.cs @@ -0,0 +1,253 @@ +// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> +// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 + +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Npgsql; + +namespace Progmasoft.Progmaweb.Api.Accounts; + +/// <summary>Which <see cref="IAccountStore"/> a configuration selects.</summary> +internal enum AccountStoreKind +{ + /// <summary><see cref="InMemoryAccountStore"/>: nothing survives a restart.</summary> + Memory, + /// <summary><see cref="PostgresAccountStore"/>: accounts and sessions survive a restart.</summary> + PostgreSql +} + +/// <summary>Chooses the account store from the configuration and registers what belongs to it.</summary> +/// <remarks> +/// <para> +/// <c>ConnectionStrings:Accounts</c> names the PostgreSQL database. <c>Accounts:Store</c> may name the store +/// outright, as <c>PostgreSQL</c> or <c>Memory</c>. Without it, a connection string selects PostgreSQL. +/// </para> +/// <para> +/// A production service that is given neither does not start. Keeping accounts in memory there has to be asked for +/// with <c>Accounts:Store=Memory</c>, because a service that quietly forgets every account at each restart is the +/// failure this store exists to prevent. Outside production the default without a connection string is memory. +/// </para> +/// </remarks> +internal static class AccountStoreRegistration +{ + /// <summary>Writes the warning of <see cref="WarnThatAccountsAreInMemory"/>.</summary> + private static readonly Action<ILogger, Exception?> InMemoryWarning = LoggerMessage.Define( + LogLevel.Warning, + new EventId(1, "AccountsInMemory"), + "Accounts are kept in memory: every account and session is lost when this service stops."); + + /// <summary>Name of the connection string of the account database.</summary> + public const string ConnectionStringName = "Accounts"; + + /// <summary>Configuration key that names the store outright.</summary> + public const string StoreKey = "Accounts:Store"; + + /// <summary>Decides which store a configuration selects.</summary> + /// <param name="configuration">The configuration of the service.</param> + /// <param name="isProduction">Whether the service runs in the production environment.</param> + /// <returns>The selected store.</returns> + /// <exception cref="InvalidOperationException"> + /// The store is named with an unknown word, PostgreSQL is named without a connection string, memory is named + /// with one, or a production service is given neither. + /// </exception> + public static AccountStoreKind Select(IConfiguration configuration, bool isProduction) + { + string? named = configuration[StoreKey]; + bool hasConnectionString = !string.IsNullOrWhiteSpace(configuration.GetConnectionString(ConnectionStringName)); + + if (string.IsNullOrWhiteSpace(named)) + { + if (hasConnectionString) + { + return AccountStoreKind.PostgreSql; + } + + return isProduction + ? throw new InvalidOperationException( + $"No account database is configured. Set ConnectionStrings:{ConnectionStringName} to the " + + $"PostgreSQL database of the accounts, or set {StoreKey}=Memory to accept that every account " + + "and session is lost when the service restarts.") + : AccountStoreKind.Memory; + } + + if (string.Equals(named, "PostgreSQL", StringComparison.OrdinalIgnoreCase)) + { + return hasConnectionString + ? AccountStoreKind.PostgreSql + : throw new InvalidOperationException( + $"{StoreKey} is PostgreSQL, and ConnectionStrings:{ConnectionStringName} is not set."); + } + + if (string.Equals(named, "Memory", StringComparison.OrdinalIgnoreCase)) + { + // Both together would leave a configured database unused without anyone noticing. + return hasConnectionString + ? throw new InvalidOperationException( + $"{StoreKey} is Memory, and ConnectionStrings:{ConnectionStringName} is set. Remove one of them.") + : AccountStoreKind.Memory; + } + + throw new InvalidOperationException($"{StoreKey} is '{named}'. It must be PostgreSQL or Memory."); + } + + /// <summary>Registers the selected store, and for PostgreSQL its schema step, health check and connections.</summary> + /// <param name="services">The services of the application.</param> + /// <param name="configuration">The configuration of the service.</param> + /// <param name="isProduction">Whether the service runs in the production environment.</param> + /// <returns>The selected store, so that the caller can report it.</returns> + public static AccountStoreKind AddAccountStore( + this IServiceCollection services, + IConfiguration configuration, + bool isProduction) + { + AccountStoreKind kind = Select(configuration, isProduction); + IHealthChecksBuilder health = services.AddHealthChecks(); + if (kind is AccountStoreKind.Memory) + { + services.AddSingleton<IAccountStore, InMemoryAccountStore>(); + } + else + { + string connectionString = configuration.GetConnectionString(ConnectionStringName)!; + // The data source owns the connection pool; the container disposes it when the service stops. + services.AddSingleton(_ => NpgsqlDataSource.Create(connectionString)); + services.AddSingleton<IAccountStore, PostgresAccountStore>(); + // Registered before the session cleanup, so the tables exist before anything reads them. + services.AddHostedService<AccountSchemaInitializer>(); + health.AddCheck<AccountDatabaseHealthCheck>("account-database"); + } + + services.AddHostedService<ExpiredSessionCleanup>(); + return kind; + } + + /// <summary>Writes to the log that this service forgets its accounts when it stops.</summary> + /// <param name="logger">The log of the service.</param> + public static void WarnThatAccountsAreInMemory(ILogger logger) => InMemoryWarning(logger, null); +} + +/// <summary>Brings the account database to the current schema before the service accepts requests.</summary> +/// <remarks> +/// A database that cannot be reached, or that is newer than the program, stops the start of the service. A service +/// that started without its tables would answer every account request with an error instead. +/// </remarks> +/// <param name="dataSource">Connections to the account database.</param> +/// <param name="logger">Receives what was done.</param> +internal sealed class AccountSchemaInitializer( + NpgsqlDataSource dataSource, + ILogger<AccountSchemaInitializer> logger) : IHostedService +{ + /// <summary>Writes the schema version and the number of steps taken at this start.</summary> + private static readonly Action<ILogger, int, int, Exception?> SchemaReport = LoggerMessage.Define<int, int>( + LogLevel.Information, + new EventId(2, "AccountSchema"), + "The account database is at schema version {Version}; {Taken} step(s) were taken at this start."); + + /// <inheritdoc/> + public async Task StartAsync(CancellationToken cancellationToken) + { + int taken = await AccountSchema.ApplyAsync(dataSource, cancellationToken); + SchemaReport(logger, AccountSchema.CurrentVersion, taken, null); + } + + /// <inheritdoc/> + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +/// <summary>Reports whether the account database answers.</summary> +/// <param name="dataSource">Connections to the account database.</param> +internal sealed class AccountDatabaseHealthCheck(NpgsqlDataSource dataSource) : IHealthCheck +{ + /// <inheritdoc/> + public async Task<HealthCheckResult> CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + await using NpgsqlCommand command = dataSource.CreateCommand("SELECT 1"); + await command.ExecuteScalarAsync(cancellationToken); + return HealthCheckResult.Healthy(); + } + catch (Exception exception) when (exception is NpgsqlException or TimeoutException or IOException) + { + // The description is written to the log of the service, never to the response of /health. + return HealthCheckResult.Unhealthy("The account database does not answer.", exception); + } + } +} + +/// <summary>Removes sessions that have expired, once at the start and then once an hour.</summary> +/// <remarks> +/// An expired session is already refused when its token is presented, and removed then. A token that is never +/// presented again would otherwise keep its row for ever; this removes those. A removal that fails is logged and +/// tried again at the next hour, because the service works without it. +/// </remarks> +/// <param name="store">The store whose sessions are removed.</param> +/// <param name="timeProvider">Clock that decides what has expired and paces the removals.</param> +/// <param name="logger">Receives what was done.</param> +internal sealed class ExpiredSessionCleanup( + IAccountStore store, + TimeProvider timeProvider, + ILogger<ExpiredSessionCleanup> logger) : BackgroundService +{ + /// <summary>Writes how many expired sessions a removal took.</summary> + private static readonly Action<ILogger, int, Exception?> RemovedReport = LoggerMessage.Define<int>( + LogLevel.Information, + new EventId(3, "ExpiredSessionsRemoved"), + "Removed {Count} expired session(s)."); + + /// <summary>Writes that a removal failed, with its cause.</summary> + private static readonly Action<ILogger, Exception?> FailureReport = LoggerMessage.Define( + LogLevel.Warning, + new EventId(4, "ExpiredSessionsNotRemoved"), + "Expired sessions could not be removed; the next hour tries again."); + + /// <summary>Time between two removals.</summary> + internal static readonly TimeSpan Interval = TimeSpan.FromHours(1); + + /// <inheritdoc/> + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using PeriodicTimer timer = new(Interval, timeProvider); + do + { + await RemoveOnceAsync(stoppingToken); + } + while (await WaitAsync(timer, stoppingToken)); + } + + /// <summary>Removes what has expired by now.</summary> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>A task that completes when the removal has run or has failed.</returns> + internal async Task RemoveOnceAsync(CancellationToken cancellationToken) + { + try + { + int removed = await store.RemoveExpiredSessionsAsync(timeProvider.GetUtcNow(), cancellationToken); + if (removed != 0) + { + RemovedReport(logger, removed, null); + } + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + FailureReport(logger, exception); + } + } + + /// <summary>Waits for the next tick; stopping the service ends the wait without an error.</summary> + /// <param name="timer">The timer that paces the removals.</param> + /// <param name="stoppingToken">Signals that the service stops.</param> + /// <returns><see langword="true"/> at a tick, <see langword="false"/> when the service stops.</returns> + private static async Task<bool> WaitAsync(PeriodicTimer timer, CancellationToken stoppingToken) + { + try + { + return await timer.WaitForNextTickAsync(stoppingToken); + } + catch (OperationCanceledException) + { + return false; + } + } +} diff --git a/apps/api/Accounts/IAccountStore.cs b/apps/api/Accounts/IAccountStore.cs index cef8bee..efb7c5b 100644 --- a/apps/api/Accounts/IAccountStore.cs +++ b/apps/api/Accounts/IAccountStore.cs @@ -6,7 +6,8 @@ namespace Progmasoft.Progmaweb.Api.Accounts; /// <summary>Persistence of accounts and sessions.</summary> /// <remarks> /// The store owns the uniqueness rules: account name, email address and Google identity are each unique, and a -/// creation that would break one of them is refused atomically. It offers no enumeration and no bulk deletion. +/// creation that would break one of them is refused atomically. It offers no enumeration of accounts and no way to +/// delete one; the only removal in bulk is that of sessions that have already expired. /// </remarks> internal interface IAccountStore { @@ -45,5 +46,11 @@ internal interface IAccountStore /// <param name="cancellationToken">Cancels the operation.</param> /// <returns>A task that completes when the session is gone.</returns> ValueTask RevokeSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken); + /// <summary>Removes every session that has expired.</summary> + /// <remarks>A session has expired when its expiry is not later than <paramref name="now"/>.</remarks> + /// <param name="now">The current moment, in UTC.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The number of sessions that were removed.</returns> + ValueTask<int> RemoveExpiredSessionsAsync(DateTimeOffset now, CancellationToken cancellationToken); } diff --git a/apps/api/Accounts/InMemoryAccountStore.cs b/apps/api/Accounts/InMemoryAccountStore.cs index 7a6930f..8606443 100644 --- a/apps/api/Accounts/InMemoryAccountStore.cs +++ b/apps/api/Accounts/InMemoryAccountStore.cs @@ -3,12 +3,11 @@ namespace Progmasoft.Progmaweb.Api.Accounts; -// This development store models the uniqueness and session semantics required by the future PostgreSQL implementation. -// It deliberately exposes no enumeration or bulk-delete surface to public endpoints. /// <summary>An <see cref="IAccountStore"/> that keeps everything in the memory of the process.</summary> /// <remarks> -/// It implements the uniqueness and session rules a durable store must keep, under one lock. Nothing survives a -/// restart of the service: every account and every session is lost when the process stops. +/// It keeps the uniqueness and session rules of <see cref="PostgresAccountStore"/> under one lock, for development +/// and for tests that need no database. Nothing survives a restart of the service: every account and every session +/// is lost when the process stops. /// </remarks> internal sealed class InMemoryAccountStore : IAccountStore { @@ -99,6 +98,12 @@ internal sealed class InMemoryAccountStore : IAccountStore cancellationToken.ThrowIfCancellationRequested(); lock (gate) { + // The durable store refuses a session of an account that does not exist; so does this one. + if (!accountsById.ContainsKey(session.AccountId)) + { + throw new InvalidOperationException("A session refers to an account that is not stored."); + } + sessionsByDigest[Convert.ToHexString(session.TokenDigest)] = session; return ValueTask.CompletedTask; } @@ -125,5 +130,20 @@ internal sealed class InMemoryAccountStore : IAccountStore return ValueTask.CompletedTask; } } + + /// <inheritdoc/> + public ValueTask<int> RemoveExpiredSessionsAsync(DateTimeOffset now, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (gate) + { + string[] expired = [.. sessionsByDigest.Where(entry => entry.Value.ExpiresAt <= now).Select(entry => entry.Key)]; + foreach (string digest in expired) + { + sessionsByDigest.Remove(digest); + } + return ValueTask.FromResult(expired.Length); + } + } } diff --git a/apps/api/Accounts/PostgresAccountStore.cs b/apps/api/Accounts/PostgresAccountStore.cs new file mode 100644 index 0000000..a0e3444 --- /dev/null +++ b/apps/api/Accounts/PostgresAccountStore.cs @@ -0,0 +1,200 @@ +// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> +// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 + +using Npgsql; + +namespace Progmasoft.Progmaweb.Api.Accounts; + +/// <summary>An <see cref="IAccountStore"/> that keeps accounts and sessions in PostgreSQL.</summary> +/// <remarks> +/// <para> +/// The uniqueness rules are unique indexes of the <c>accounts</c> table, so two requests that race for one name, +/// address or Google identity are decided by the database: one row is stored and the other request is refused. A +/// session row refers to its account, and the database refuses a session of an account that does not exist. +/// </para> +/// <para> +/// PostgreSQL keeps a timestamp to the microsecond. A moment is cut to that precision before it is stored, and the +/// store returns what it stored, so a value read later equals the value returned when it was written. +/// </para> +/// </remarks> +/// <param name="dataSource">Connections to the database that holds the tables of <see cref="AccountSchema"/>.</param> +internal sealed class PostgresAccountStore(NpgsqlDataSource dataSource) : IAccountStore +{ + /// <summary>How often a creation is tried again when it was refused and no rule that refuses it is found.</summary> + private const int CreateAttempts = 3; + + /// <summary>The columns of an account, in the order <see cref="ReadAccount"/> reads them.</summary> + private const string AccountColumns = + "id, account_name, normalized_account_name, email, normalized_email, google_subject, password_hash, created_at"; + + /// <inheritdoc/> + public async ValueTask<CreateAccountResult> CreateAsync(AccountRecord account, CancellationToken cancellationToken) + { + AccountRecord stored = account with { CreatedAt = ToStoredPrecision(account.CreatedAt) }; + + for (int attempt = 0; attempt < CreateAttempts; attempt++) + { + // A row that breaks a unique index is skipped instead of raising an error, so that a taken name is an + // ordinary answer and the transaction log holds no failed statement for it. + await using (NpgsqlCommand insert = dataSource.CreateCommand( + $"INSERT INTO accounts ({AccountColumns}) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT DO NOTHING")) + { + insert.Parameters.AddWithValue(stored.Id); + insert.Parameters.AddWithValue(stored.AccountName); + insert.Parameters.AddWithValue(stored.NormalizedAccountName); + insert.Parameters.AddWithValue(stored.Email); + insert.Parameters.AddWithValue(stored.NormalizedEmail); + insert.Parameters.AddWithValue(NullableText(stored.GoogleSubject)); + insert.Parameters.AddWithValue(NullableText(stored.PasswordHash)); + insert.Parameters.AddWithValue(stored.CreatedAt); + if (await insert.ExecuteNonQueryAsync(cancellationToken) == 1) + { + return CreateAccountResult.Created(stored); + } + } + + // The rules are reported in one fixed order, the order of the in-memory store, whichever index the + // database happened to check first. + await using NpgsqlCommand taken = dataSource.CreateCommand( + """ + SELECT EXISTS (SELECT 1 FROM accounts WHERE google_subject = $1), + EXISTS (SELECT 1 FROM accounts WHERE normalized_account_name = $2), + EXISTS (SELECT 1 FROM accounts WHERE normalized_email = $3) + """); + taken.Parameters.AddWithValue(NullableText(stored.GoogleSubject)); + taken.Parameters.AddWithValue(stored.NormalizedAccountName); + taken.Parameters.AddWithValue(stored.NormalizedEmail); + await using NpgsqlDataReader reader = await taken.ExecuteReaderAsync(cancellationToken); + await reader.ReadAsync(cancellationToken); + if (reader.GetBoolean(0)) + { + return CreateAccountResult.GoogleSubjectUnavailable(); + } + + if (reader.GetBoolean(1)) + { + return CreateAccountResult.AccountNameUnavailable(); + } + + if (reader.GetBoolean(2)) + { + return CreateAccountResult.EmailUnavailable(); + } + } + + // Accounts are never deleted, so a refused row always has a row that refuses it. Reaching this line means + // the identifier itself was taken, which a random identifier does not do. + throw new InvalidOperationException("The account store refused an account without a rule that explains it."); + } + + /// <inheritdoc/> + public ValueTask<AccountRecord?> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken) => + FindAccountAsync("normalized_email", normalizedEmail, cancellationToken); + + /// <inheritdoc/> + public ValueTask<AccountRecord?> FindByGoogleSubjectAsync( + string googleSubject, + CancellationToken cancellationToken) => + FindAccountAsync("google_subject", googleSubject, cancellationToken); + + /// <inheritdoc/> + public ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken) => + FindAccountAsync("id", id, cancellationToken); + + /// <inheritdoc/> + public async ValueTask StoreSessionAsync(SessionRecord session, CancellationToken cancellationToken) + { + await using NpgsqlCommand command = dataSource.CreateCommand( + """ + INSERT INTO sessions (token_digest, account_id, created_at, expires_at) VALUES ($1, $2, $3, $4) + ON CONFLICT (token_digest) DO UPDATE + SET account_id = EXCLUDED.account_id, created_at = EXCLUDED.created_at, expires_at = EXCLUDED.expires_at + """); + command.Parameters.AddWithValue(session.TokenDigest); + command.Parameters.AddWithValue(session.AccountId); + command.Parameters.AddWithValue(ToStoredPrecision(session.CreatedAt)); + command.Parameters.AddWithValue(ToStoredPrecision(session.ExpiresAt)); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + /// <inheritdoc/> + public async ValueTask<SessionRecord?> FindSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken) + { + await using NpgsqlCommand command = dataSource.CreateCommand( + "SELECT token_digest, account_id, created_at, expires_at FROM sessions WHERE token_digest = $1"); + command.Parameters.AddWithValue(tokenDigest); + await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return null; + } + + return new SessionRecord( + reader.GetFieldValue<byte[]>(0), + reader.GetGuid(1), + reader.GetFieldValue<DateTimeOffset>(2), + reader.GetFieldValue<DateTimeOffset>(3)); + } + + /// <inheritdoc/> + public async ValueTask RevokeSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken) + { + await using NpgsqlCommand command = dataSource.CreateCommand("DELETE FROM sessions WHERE token_digest = $1"); + command.Parameters.AddWithValue(tokenDigest); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + /// <inheritdoc/> + public async ValueTask<int> RemoveExpiredSessionsAsync(DateTimeOffset now, CancellationToken cancellationToken) + { + await using NpgsqlCommand command = dataSource.CreateCommand("DELETE FROM sessions WHERE expires_at <= $1"); + command.Parameters.AddWithValue(now.ToUniversalTime()); + return await command.ExecuteNonQueryAsync(cancellationToken); + } + + /// <summary>Finds the one account whose column has a value.</summary> + /// <typeparam name="TKey">Type of the value.</typeparam> + /// <param name="column">Name of a column with a unique index. It is written into the statement.</param> + /// <param name="value">The value to look for.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The account, or <see langword="null"/> when no account has the value.</r |