diff --git a/.github/actions/cloud-database/aurora-postgresql.mts b/.github/actions/cloud-database/aurora-postgresql.mts index faa243609b..9df5b0f2b6 100644 --- a/.github/actions/cloud-database/aurora-postgresql.mts +++ b/.github/actions/cloud-database/aurora-postgresql.mts @@ -1,10 +1,11 @@ // Provisions and tears down an Aurora PostgreSQL cluster for one run of the cloud database tests. -import { step, run, capture, newAdminPassword, runnerIpAddress, setPersistenceConnectionString, verifyDatabase, teardownStep } from './common.mts'; +import { step, run, capture, newAdminPassword, runnerIpAddress, setPersistenceConnectionString, setTokenConnectionString, runScript, verifyDatabase, teardownStep } from './common.mts'; import * as aws from './aws.mts'; const databaseName = 'servicecontrol'; const adminUser = 'sctestadmin'; +const iamUser = 'sc_iam'; const port = 5432; function resources(name: string) { @@ -36,6 +37,7 @@ async function provision(name: string): Promise { '--db-subnet-group-name', aws.dbSubnetGroupName(), '--no-deletion-protection', '--backup-retention-period', '1', + '--enable-iam-database-authentication', ...tags, '--no-cli-pager']); @@ -61,7 +63,11 @@ async function provision(name: string): Promise { step('Waiting until the database accepts connections'); verifyDatabase('PostgreSql', connectionString); + step(`Creating ${iamUser}, which signs in with an IAM token`); + runScript('./create-iam-user-postgresql.cs', [connectionString, iamUser]); + setPersistenceConnectionString('PostgreSql', connectionString); + setTokenConnectionString('PostgreSql', `Host=${endpoint};Port=${port};Database=${databaseName};Username=${iamUser};Ssl Mode=Require;Trust Server Certificate=true;Timeout=60;Token Authentication=AwsRdsIam`); } function teardown(name: string): void { diff --git a/.github/actions/cloud-database/aws-iam-policy.json b/.github/actions/cloud-database/aws-iam-policy.json index 032603043c..476a86a9b5 100644 --- a/.github/actions/cloud-database/aws-iam-policy.json +++ b/.github/actions/cloud-database/aws-iam-policy.json @@ -54,6 +54,14 @@ "rds:AddTagsToResource" ], "Resource": "*" + }, + { + "Sid": "ConnectAsTestIamUser", + "Effect": "Allow", + "Action": [ + "rds-db:connect" + ], + "Resource": "arn:aws:rds-db:*:*:dbuser:*/sc_iam" } ] } diff --git a/.github/actions/cloud-database/azure-postgresql.mts b/.github/actions/cloud-database/azure-postgresql.mts index ff1cc7efde..d926c2199d 100644 --- a/.github/actions/cloud-database/azure-postgresql.mts +++ b/.github/actions/cloud-database/azure-postgresql.mts @@ -1,10 +1,11 @@ -import { step, run, newAdminPassword, runnerIpAddress, setPersistenceConnectionString, verifyDatabase, teardownStep } from './common.mts'; +import { step, run, newAdminPassword, runnerIpAddress, setPersistenceConnectionString, setTokenConnectionString, verifyDatabase, teardownStep } from './common.mts'; import * as azure from './azure.mts'; const databaseName = 'servicecontrol'; const adminUser = 'sctestadmin'; async function provision(name: string): Promise { + const principal = azure.signedInPrincipal(); const password = newAdminPassword(); const runnerIp = await runnerIpAddress(); const location = azure.location(); @@ -17,6 +18,8 @@ async function provision(name: string): Promise { '--location', location, '--admin-user', adminUser, `--admin-password=${password}`, + '--password-auth', 'Enabled', + '--microsoft-entra-auth', 'Enabled', '--tier', 'GeneralPurpose', '--sku-name', 'Standard_D8ds_v5', '--storage-size', '512', @@ -27,6 +30,16 @@ async function provision(name: string): Promise { '--yes', '--only-show-errors', '--output', 'none']); + // The display name becomes the PostgreSQL role, which is the Username a token login gives. + step(`Making service principal ${principal.clientId} a Microsoft Entra admin of ${name}`); + run('az', ['postgres', 'flexible-server', 'microsoft-entra-admin', 'create', + '--resource-group', azure.resourceGroup, + '--server-name', name, + '--display-name', principal.clientId, + '--object-id', principal.objectId, + '--type', 'ServicePrincipal', + '--only-show-errors', '--output', 'none']); + step(`Creating database ${databaseName}`); run('az', ['postgres', 'flexible-server', 'db', 'create', '--name', databaseName, @@ -40,6 +53,7 @@ async function provision(name: string): Promise { verifyDatabase('PostgreSql', connectionString); setPersistenceConnectionString('PostgreSql', connectionString); + setTokenConnectionString('PostgreSql', `Host=${name}.postgres.database.azure.com;Port=5432;Database=${databaseName};Username=${principal.clientId};Ssl Mode=Require;Timeout=60;Token Authentication=AzureEntraId`); } function teardown(name: string): void { diff --git a/.github/actions/cloud-database/azure-sqlserver.mts b/.github/actions/cloud-database/azure-sqlserver.mts index 09a4e39a37..109ee2a45c 100644 --- a/.github/actions/cloud-database/azure-sqlserver.mts +++ b/.github/actions/cloud-database/azure-sqlserver.mts @@ -1,22 +1,27 @@ -import { step, run, newAdminPassword, runnerIpAddress, setPersistenceConnectionString, verifyDatabase, teardownStep } from './common.mts'; +import { step, run, newAdminPassword, runnerIpAddress, setPersistenceConnectionString, setTokenConnectionString, verifyDatabase, teardownStep } from './common.mts'; import * as azure from './azure.mts'; const databaseName = 'servicecontrol'; const adminUser = 'sctestadmin'; async function provision(name: string): Promise { + const principal = azure.signedInPrincipal(); const password = newAdminPassword(); const runnerIp = await runnerIpAddress(); const location = azure.location(); const tags = azure.tags(name); - step(`Creating SQL server ${name} in ${azure.resourceGroup} (${location})`); + // For an application the sid is its client ID, where PostgreSQL takes the object ID. + step(`Creating SQL server ${name} in ${azure.resourceGroup} (${location}), with service principal ${principal.clientId} as its Microsoft Entra admin`); run('az', ['sql', 'server', 'create', '--name', name, '--resource-group', azure.resourceGroup, '--location', location, '--admin-user', adminUser, `--admin-password=${password}`, + '--external-admin-principal-type', 'Application', + '--external-admin-name', principal.clientId, + '--external-admin-sid', principal.clientId, ...tags, '--only-show-errors', '--output', 'none']); @@ -45,6 +50,7 @@ async function provision(name: string): Promise { verifyDatabase('SqlServer', connectionString); setPersistenceConnectionString('SqlServer', connectionString); + setTokenConnectionString('SqlServer', `Server=tcp:${name}.database.windows.net,1433;Initial Catalog=${databaseName};Authentication=Active Directory Default;Encrypt=True;TrustServerCertificate=False;Connect Timeout=60`); } function teardown(name: string): void { diff --git a/.github/actions/cloud-database/azure.mts b/.github/actions/cloud-database/azure.mts index 9d6c76178d..97a5f4b0c5 100644 --- a/.github/actions/cloud-database/azure.mts +++ b/.github/actions/cloud-database/azure.mts @@ -20,4 +20,24 @@ function location(): string { return value; } -export { resourceGroup, tags, location }; +// Read from the CLI's own access token, because looking the principal up in Microsoft Graph needs a directory permission that the login is not guaranteed to have. +function signedInPrincipal(): { objectId: string; clientId: string } { + const type = capture('az', ['account', 'show', '--query', 'user.type', '--output', 'tsv']); + + if (type !== 'servicePrincipal') { + throw new Error(`The Azure CLI is signed in as a ${type}, and the Microsoft Entra admin has to be a service principal.`); + } + + const token = capture('az', ['account', 'get-access-token', '--query', 'accessToken', '--output', 'tsv']); + const claims = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString('utf8')); + const objectId: string | undefined = claims.oid; + const clientId: string | undefined = claims.appid ?? claims.azp; + + if (!objectId || !clientId) { + throw new Error('The Azure CLI access token has no oid or appid claim, so the signed-in service principal cannot be identified.'); + } + + return { objectId, clientId }; +} + +export { resourceGroup, tags, location, signedInPrincipal }; diff --git a/.github/actions/cloud-database/common.mts b/.github/actions/cloud-database/common.mts index 72d5fb9980..42c3934094 100644 --- a/.github/actions/cloud-database/common.mts +++ b/.github/actions/cloud-database/common.mts @@ -76,17 +76,26 @@ async function runnerIpAddress(): Promise { return (await response.text()).trim(); } -function setPersistenceConnectionString(provider: Provider, connectionString: string): void { - const name = `ServiceControl_Persistence_${provider}_ConnectionString`; - appendFileSync(process.env.GITHUB_ENV, `${name}=${connectionString}\n`); +function exportVariable(name: string, value: string): void { + appendFileSync(process.env.GITHUB_ENV, `${name}=${value}\n`); step(`Exported ${name}`); } +function setPersistenceConnectionString(provider: Provider, connectionString: string): void { + exportVariable(`ServiceControl_Persistence_${provider}_ConnectionString`, connectionString); +} + +function setTokenConnectionString(provider: Provider, connectionString: string): void { + exportVariable(`ServiceControl_Persistence_${provider}_TokenConnectionString`, connectionString); +} + +function runScript(script: string, args: string[]): void { + execFileSync('dotnet', ['run', script, '--', ...args], { cwd: import.meta.dirname, stdio: 'inherit' }); +} + // Waits until the database is really reachable. function verifyDatabase(provider: Provider, connectionString: string): void { - const script = provider === 'SqlServer' ? './verify-sqlserver.cs' : './verify-postgresql.cs'; - - execFileSync('dotnet', ['run', script, '--', connectionString], { cwd: import.meta.dirname, stdio: 'inherit' }); + runScript(provider === 'SqlServer' ? './verify-sqlserver.cs' : './verify-postgresql.cs', [connectionString]); } // Teardown runs even when provisioning failed part way, so it has to tolerate resources that were @@ -110,6 +119,8 @@ export { newAdminPassword, runnerIpAddress, setPersistenceConnectionString, + setTokenConnectionString, + runScript, verifyDatabase, teardownStep }; diff --git a/.github/actions/cloud-database/create-iam-user-postgresql.cs b/.github/actions/cloud-database/create-iam-user-postgresql.cs new file mode 100644 index 0000000000..e9821e68b5 --- /dev/null +++ b/.github/actions/cloud-database/create-iam-user-postgresql.cs @@ -0,0 +1,36 @@ +#:package Npgsql@10.0.3 + +// Creates a login that signs in with an IAM token and can create its own schema in the test database. A role that is granted rds_iam can no longer sign in with a password. + +using Npgsql; + +if (args.Length != 2) +{ + Console.Error.WriteLine("usage: dotnet run create-iam-user-postgresql.cs -- "); + return 1; +} + +var builder = new NpgsqlConnectionStringBuilder(args[0]); +var user = args[1]; + +if (string.Equals(user, builder.Username, StringComparison.Ordinal)) +{ + Console.Error.WriteLine($"Refusing to grant rds_iam to '{user}', the user this script signs in as."); + return 1; +} + +await using var connection = new NpgsqlConnection(builder.ConnectionString); +await connection.OpenAsync(); + +await using var command = connection.CreateCommand(); +command.CommandText = $""" + CREATE ROLE {QuoteIdentifier(user)} WITH LOGIN; + GRANT rds_iam TO {QuoteIdentifier(user)}; + GRANT CREATE ON DATABASE {QuoteIdentifier(builder.Database!)} TO {QuoteIdentifier(user)}; + """; +await command.ExecuteNonQueryAsync(); + +Console.WriteLine($"Created '{user}' on {builder.Host}, which signs in to '{builder.Database}' with an IAM token."); +return 0; + +static string QuoteIdentifier(string identifier) => $"\"{identifier.Replace("\"", "\"\"")}\""; diff --git a/.github/actions/cloud-database/verify-sqlserver.cs b/.github/actions/cloud-database/verify-sqlserver.cs index a2fa71435d..55d13f27e5 100644 --- a/.github/actions/cloud-database/verify-sqlserver.cs +++ b/.github/actions/cloud-database/verify-sqlserver.cs @@ -1,4 +1,4 @@ -#:package Microsoft.Data.SqlClient@6.1.1 +#:package Microsoft.Data.SqlClient@7.1.1 // Waits until the server accepts connections, creates the test database if the provisioning CLI // could not, fails the run if the server has no Full-Text Search, and waits until a full-text diff --git a/docs/README.md b/docs/README.md index cf6104c7d6..c03acbd848 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ ServiceControl is the monitoring component of the Particular Service Platform: i - [ServiceControl documentation](https://docs.particular.net/servicecontrol/) — public documentation entry point - [README.md](../README.md) — how to run and debug ServiceControl, ServiceControl.Audit, and ServiceControl.Monitoring locally -- [Testing overview](testing.md) — index of test kinds, local setup for each, and the scenario checklists to rerun after a change +- [Testing overview](testing.md) — index of test kinds, local setup for each, the cloud database tests, and the scenario checklists to rerun after a change - [Coding and design guidelines](coding-and-design-guidelines.md) — conventions for new code - [Deployment](deployment.md) and [Packaging](packaging.md) — how instances are packaged and deployed diff --git a/docs/cloud-database-tests.md b/docs/cloud-database-tests.md new file mode 100644 index 0000000000..8856c4f125 --- /dev/null +++ b/docs/cloud-database-tests.md @@ -0,0 +1,26 @@ +# Cloud database tests + +The `Cloud database tests` workflow (`.github/workflows/cloud-database-tests.yml`) runs the SQL Server and PostgreSQL persistence and acceptance suites against managed databases. Every target runs nightly at 03:17 UTC on the default branch, and also whenever the release workflow calls it. To run it yourself, start it from the Actions tab and choose one target, or `all`. Starting a run cancels any run of the workflow already in progress on the same branch. The nightly run skips a commit that has already passed a nightly run, and posts to the `pulse-and-control` Slack channel when a target fails or is cancelled. For each target, the `.github/actions/cloud-database` action creates a server for the run, exports the connection strings below, and deletes the server when the job ends. + +| Variable | Targets | Signs in with | +| --- | --- | --- | +| `ServiceControl_Persistence_SqlServer_ConnectionString` | `azure-sqlserver`, `rds-sqlserver` | The admin user name and password | +| `ServiceControl_Persistence_PostgreSql_ConnectionString` | `azure-postgresql`, `aurora-postgresql` | The admin user name and password | +| `ServiceControl_Persistence_SqlServer_TokenConnectionString` | `azure-sqlserver` | A Microsoft Entra ID token | +| `ServiceControl_Persistence_PostgreSql_TokenConnectionString` | `azure-postgresql`, `aurora-postgresql` | A Microsoft Entra ID token on Azure, an IAM token on Aurora | + +## Token authentication fixtures + +The suites use the password connection strings. Only the token authentication fixtures use the token connection strings, and each fixture is ignored when its variable is not set. A token fixture applies the migrations to a new schema, writes a row and reads it back, then drops the schema, all through the token login. + +* `azure-sqlserver` makes the service principal that `azure/login` signed in as the server's Microsoft Entra admin and keeps SQL authentication enabled. The connection string has `Authentication=Active Directory Default` and no `User ID` or `Password`. SqlClient gets the token from `DefaultAzureCredential`, which finds the Azure CLI sign-in on the runner. +* `azure-postgresql` enables Microsoft Entra authentication alongside password authentication and makes the same service principal a Microsoft Entra admin of the server. The admin's role name is the principal's client ID. The connection string uses that client ID as its `Username` and sets `Token Authentication=AzureEntraId`. +* `aurora-postgresql` enables IAM database authentication on the cluster and creates an `sc_iam` user that has `rds_iam` and `CREATE` on the test database. The master user must not get `rds_iam`: a user with that role can no longer sign in with a password, and the suites sign in as the master user. The connection string sets `Token Authentication=AwsRdsIam`. + +## AWS permissions + +The AWS identity that the workflow uses needs `rds-db:connect` for `sc_iam`. The file `.github/actions/cloud-database/aws-iam-policy.json` lists every permission that identity needs, this one included. The workflow does not apply the file, so add a new permission to the identity by hand before the first run that depends on it. + +## Running a token fixture locally + +Set the fixture's variable to a connection string for a server that accepts your own token login. For example, use a server where you are the Microsoft Entra admin and have signed in with `az login`. diff --git a/docs/testing.md b/docs/testing.md index d5d115354b..0c09fe8a10 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -24,7 +24,7 @@ On Windows, [PackagedRavenDBTests](../src/ServiceControlInstaller.Engine.UnitTes ## Persistence tests -Persistence tests check assumptions at the persistence seam level by exercising each persister. For local setup details, see [Local testing of persistence providers](testing-persistence.md). +Persistence tests check assumptions at the persistence seam level by exercising each persister. For local setup details, see [Local testing of persistence providers](testing-persistence.md). For the workflow that runs these suites against managed cloud databases, see [Cloud database tests](cloud-database-tests.md). The RavenDB persistence test projects contain an `IndexDefinitionApprovals` test that approves the definition of every index the persister deploys. RavenDB rebuilds an index side by side when its definition changes, and the rebuild of a large database can take days. When the test fails, record the changed indexes in the upgrade guide of the next release before you approve the new definition. An operator who locked an index must unlock it to receive the change. diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 1ea18344b5..915c1e207c 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -6,7 +6,9 @@ + + diff --git a/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs b/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs index bf8d700e80..b91b958ec6 100644 --- a/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs +++ b/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs @@ -5,30 +5,40 @@ namespace ServiceControl.Audit.Persistence.EFCore.PostgreSql; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Npgsql; +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; using NServiceBus; using ServiceControl.Audit.Persistence.EFCore.Abstractions; using ServiceControl.Audit.Persistence.EFCore.DbContexts; using ServiceControl.Audit.Persistence.EFCore.Infrastructure; +using ServiceControl.PostgreSql.TokenAuthentication; -class PostgreSqlPersistence(EFPersisterSettings settings) : EFPersistenceBase(settings) +class PostgreSqlPersistence(PostgreSqlPersisterSettings settings) : EFPersistenceBase(settings) { + readonly PostgreSqlTokenAuthentication? tokenAuthentication = settings.TokenAuthentication; + protected override void AddDbContext(IServiceCollection services, EFPersisterSettings settings) { - services.AddDbContext(options => + if (tokenAuthentication is { } authentication) + { + services.TryAddSingleton(_ => authentication.CreateTokenProvider()); + services.TryAddSingleton(serviceProvider => PostgreSqlTokenDataSource.Build(settings.ConnectionString, serviceProvider.GetRequiredService(), TokenLogger(serviceProvider))); + services.TryAddKeyedSingleton(PostgreSqlRetentionLock.TokenDataSourceKey, (serviceProvider, _) => + PostgreSqlTokenDataSource.Build(PostgreSqlRetentionLock.Unpooled(settings.ConnectionString), serviceProvider.GetRequiredService(), TokenLogger(serviceProvider))); + } + + services.AddDbContext((serviceProvider, options) => { - options.UseNpgsql(settings.ConnectionString, npgsqlOptions => + if (tokenAuthentication is null) { - npgsqlOptions.CommandTimeout(settings.CommandTimeout); - npgsqlOptions.MigrationsHistoryTable(AuditDbContext.MigrationsHistoryTableName, settings.Schema); - - if (settings.EnableRetryOnFailure) - { - npgsqlOptions.EnableRetryOnFailure( - maxRetryCount: settings.MaxRetryCount, - maxRetryDelay: TimeSpan.FromSeconds(settings.MaxRetryDelayInSeconds), - errorCodesToAdd: null); - } - }); + options.UseNpgsql(settings.ConnectionString, ConfigureNpgsql); + } + else + { + options.UseNpgsql(serviceProvider.GetRequiredService(), ConfigureNpgsql); + } options.ReplaceService(); @@ -48,8 +58,24 @@ protected override void AddDbContext(IServiceCollection services, EFPersisterSet }, ServiceLifetime.Scoped); services.AddScoped(provider => provider.GetRequiredService()); + + void ConfigureNpgsql(NpgsqlDbContextOptionsBuilder npgsqlOptions) + { + npgsqlOptions.CommandTimeout(settings.CommandTimeout); + npgsqlOptions.MigrationsHistoryTable(AuditDbContext.MigrationsHistoryTableName, settings.Schema); + + if (settings.EnableRetryOnFailure) + { + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: settings.MaxRetryCount, + maxRetryDelay: TimeSpan.FromSeconds(settings.MaxRetryDelayInSeconds), + errorCodesToAdd: null); + } + } } + static ILogger TokenLogger(IServiceProvider serviceProvider) => serviceProvider.GetRequiredService().CreateLogger(typeof(PostgreSqlTokenDataSource)); + protected override void AddPartitionManager(IServiceCollection services) => services.AddSingleton(); diff --git a/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/PostgreSqlPersistenceConfiguration.cs b/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/PostgreSqlPersistenceConfiguration.cs index dabb3c7cf7..43696068e5 100644 --- a/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/PostgreSqlPersistenceConfiguration.cs +++ b/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/PostgreSqlPersistenceConfiguration.cs @@ -17,6 +17,6 @@ protected override IPersistence Create(EFPersisterSettings settings) $"AuditRetentionPeriod is {settings.AuditRetentionPeriod}, but PostgreSQL storage keeps audit data for 1 to 90 days. Set AuditRetentionPeriod within that range."); } - return new PostgreSqlPersistence(settings); + return new PostgreSqlPersistence(new PostgreSqlPersisterSettings(settings)); } } diff --git a/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/PostgreSqlPersisterSettings.cs b/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/PostgreSqlPersisterSettings.cs new file mode 100644 index 0000000000..d0270a602b --- /dev/null +++ b/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/PostgreSqlPersisterSettings.cs @@ -0,0 +1,30 @@ +namespace ServiceControl.Audit.Persistence.EFCore.PostgreSql; + +using System.Diagnostics.CodeAnalysis; +using ServiceControl.Audit.Persistence.EFCore.Abstractions; +using ServiceControl.PostgreSql.TokenAuthentication; + +class PostgreSqlPersisterSettings : EFPersisterSettings +{ + /// + /// Parses and removes the token authentication keywords, so every reader gets a string Npgsql accepts. + /// + [SetsRequiredMembers] + public PostgreSqlPersisterSettings(EFPersisterSettings settings) + { + TokenAuthentication = PostgreSqlTokenAuthentication.Parse(settings.ConnectionString, EFPersistenceConfigurationBase.ConnectionStringKey, out var npgsqlConnectionString); + ConnectionString = npgsqlConnectionString; + Schema = settings.Schema; + CommandTimeout = settings.CommandTimeout; + QueryTimeout = settings.QueryTimeout; + AuditRetentionPeriod = settings.AuditRetentionPeriod; + BodyStorage = settings.BodyStorage; + MaxBodySizeToStore = settings.MaxBodySizeToStore; + MaxRetryCount = settings.MaxRetryCount; + MaxRetryDelayInSeconds = settings.MaxRetryDelayInSeconds; + EnableRetryOnFailure = settings.EnableRetryOnFailure; + EnableSensitiveDataLogging = settings.EnableSensitiveDataLogging; + } + + public PostgreSqlTokenAuthentication? TokenAuthentication { get; } +} diff --git a/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/PostgreSqlRetentionLock.cs b/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/PostgreSqlRetentionLock.cs index 3e6e1bdc34..acde9432d7 100644 --- a/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/PostgreSqlRetentionLock.cs +++ b/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/PostgreSqlRetentionLock.cs @@ -1,19 +1,27 @@ namespace ServiceControl.Audit.Persistence.EFCore.PostgreSql; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Npgsql; using ServiceControl.Audit.Persistence.EFCore.Abstractions; using ServiceControl.Audit.Persistence.EFCore.Infrastructure; -class PostgreSqlRetentionLock(EFPersisterSettings settings, ILogger logger) : IRetentionLock +class PostgreSqlRetentionLock( + EFPersisterSettings settings, + ILogger logger, + [FromKeyedServices(PostgreSqlRetentionLock.TokenDataSourceKey)] NpgsqlDataSource? tokenDataSource = null) : IRetentionLock { - // Unpooled, because a session lock would stay with a pooled connection after it returns to the pool. - readonly string connectionString = new NpgsqlConnectionStringBuilder(settings.ConnectionString) { Pooling = false }.ConnectionString; + public const string TokenDataSourceKey = nameof(PostgreSqlRetentionLock); + + readonly string connectionString = Unpooled(settings.ConnectionString); readonly string resource = RetentionLock.ResourceName(settings.Schema); + // Unpooled, because a session lock would stay with a pooled connection after it returns to the pool. + public static string Unpooled(string connectionString) => new NpgsqlConnectionStringBuilder(connectionString) { Pooling = false }.ConnectionString; + public async Task TryAcquire(CancellationToken cancellationToken = default) { - var connection = new NpgsqlConnection(connectionString); + var connection = tokenDataSource?.CreateConnection() ?? new NpgsqlConnection(connectionString); var acquired = false; try { diff --git a/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/ServiceControl.Audit.Persistence.EFCore.PostgreSql.csproj b/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/ServiceControl.Audit.Persistence.EFCore.PostgreSql.csproj index fce1f0a48a..ff58fcbb9f 100644 --- a/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/ServiceControl.Audit.Persistence.EFCore.PostgreSql.csproj +++ b/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/ServiceControl.Audit.Persistence.EFCore.PostgreSql.csproj @@ -28,6 +28,17 @@ + + + + + + + + + + + diff --git a/src/ServiceControl.Audit.Persistence.Tests.PostgreSql/PostgreSqlEnvironmentDataTests.cs b/src/ServiceControl.Audit.Persistence.Tests.PostgreSql/PostgreSqlEnvironmentDataTests.cs index 64bbcb0f6e..11a01b9584 100644 --- a/src/ServiceControl.Audit.Persistence.Tests.PostgreSql/PostgreSqlEnvironmentDataTests.cs +++ b/src/ServiceControl.Audit.Persistence.Tests.PostgreSql/PostgreSqlEnvironmentDataTests.cs @@ -67,16 +67,13 @@ public async Task Measures_and_identifies_the_search_path_schema_when_none_is_co var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(configuration.TimeProvider); - - var settings = new EFPersisterSettings + new PostgreSqlPersistence(new PostgreSqlPersisterSettings(new EFPersisterSettings { ConnectionString = new NpgsqlConnectionStringBuilder(configuration.ConnectionString) { SearchPath = configuration.Schema }.ConnectionString, AuditRetentionPeriod = TimeSpan.FromDays(1), MaxBodySizeToStore = 100000, BodyStorage = new FileSystemBodyStorageSettings { StoragePath = configuration.BodyStoragePath } - }; - - new PostgreSqlPersistence(settings).AddPersistence(services); + })).AddPersistence(services); await using var unconfigured = services.BuildServiceProvider(); var identity = await unconfigured.GetRequiredService().GetIdentity(TestTimeoutCancellationToken); diff --git a/src/ServiceControl.Audit.Persistence.Tests.PostgreSql/PostgreSqlTokenAuthenticationTests.cs b/src/ServiceControl.Audit.Persistence.Tests.PostgreSql/PostgreSqlTokenAuthenticationTests.cs new file mode 100644 index 0000000000..418d916bc4 --- /dev/null +++ b/src/ServiceControl.Audit.Persistence.Tests.PostgreSql/PostgreSqlTokenAuthenticationTests.cs @@ -0,0 +1,77 @@ +namespace ServiceControl.Audit.Persistence.Tests +{ + using System; + using NUnit.Framework; + using ServiceControl.Audit.Persistence.EFCore.Abstractions; + using ServiceControl.Audit.Persistence.EFCore.PostgreSql; + using ServiceControl.PostgreSql.TokenAuthentication; + + [TestFixture] + class PostgreSqlTokenAuthenticationTests + { + [Test] + public void Leaves_a_connection_string_without_token_keywords_untouched() + { + const string configured = "host=db.example.com; Username = sc ;Password='p;w';Application Name=\"ServiceControl\""; + + var settings = new PostgreSqlPersisterSettings(new EFPersisterSettings + { + ConnectionString = configured, + AuditRetentionPeriod = TimeSpan.FromDays(1), + MaxBodySizeToStore = 100000 + }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(settings.ConnectionString, Is.EqualTo(configured)); + Assert.That(settings.TokenAuthentication, Is.Null); + } + } + + [Test] + public void Keeps_every_other_setting() + { + var configured = new EFPersisterSettings + { + ConnectionString = "Host=db.example.com;Username=sc;Token Authentication=AwsRdsIam", + Schema = "sc_custom", + CommandTimeout = 41, + QueryTimeout = TimeSpan.FromSeconds(42), + AuditRetentionPeriod = TimeSpan.FromDays(43), + MaxBodySizeToStore = 44, + MaxRetryCount = 45, + MaxRetryDelayInSeconds = 46, + EnableRetryOnFailure = false, + EnableSensitiveDataLogging = true + }; + + var settings = new PostgreSqlPersisterSettings(configured); + + using (Assert.EnterMultipleScope()) + { + Assert.That(settings.ConnectionString, Does.Not.Contain("Token").IgnoreCase); + Assert.That(settings.TokenAuthentication, Is.InstanceOf()); + Assert.That(settings.Schema, Is.EqualTo(configured.Schema)); + Assert.That(settings.CommandTimeout, Is.EqualTo(configured.CommandTimeout)); + Assert.That(settings.QueryTimeout, Is.EqualTo(configured.QueryTimeout)); + Assert.That(settings.AuditRetentionPeriod, Is.EqualTo(configured.AuditRetentionPeriod)); + Assert.That(settings.MaxBodySizeToStore, Is.EqualTo(configured.MaxBodySizeToStore)); + Assert.That(settings.MaxRetryCount, Is.EqualTo(configured.MaxRetryCount)); + Assert.That(settings.MaxRetryDelayInSeconds, Is.EqualTo(configured.MaxRetryDelayInSeconds)); + Assert.That(settings.EnableRetryOnFailure, Is.EqualTo(configured.EnableRetryOnFailure)); + Assert.That(settings.EnableSensitiveDataLogging, Is.EqualTo(configured.EnableSensitiveDataLogging)); + } + } + + [Test] + public void Rejects_invalid_keywords_when_the_persistence_is_created() + { + var settings = new PersistenceSettings(TimeSpan.FromDays(1), true, 100000); + settings.PersisterSpecificSettings[EFPersistenceConfigurationBase.ConnectionStringKey] = "Host=db.example.com;Token Authentication=AwsRdsIam"; + + var exception = Assert.Throws(() => new PostgreSqlPersistenceConfiguration().Create(settings)); + + Assert.That(exception.Message, Does.StartWith("Database/ConnectionString is invalid.").And.Contain("Username")); + } + } +} diff --git a/src/ServiceControl.Audit.Persistence.Tests.PostgreSql/ServiceControl.Audit.Persistence.Tests.PostgreSql.csproj b/src/ServiceControl.Audit.Persistence.Tests.PostgreSql/ServiceControl.Audit.Persistence.Tests.PostgreSql.csproj index afb5f35f72..afe4b35ba2 100644 --- a/src/ServiceControl.Audit.Persistence.Tests.PostgreSql/ServiceControl.Audit.Persistence.Tests.PostgreSql.csproj +++ b/src/ServiceControl.Audit.Persistence.Tests.PostgreSql/ServiceControl.Audit.Persistence.Tests.PostgreSql.csproj @@ -33,6 +33,7 @@ + diff --git a/src/ServiceControl.Audit.Persistence.Tests.PostgreSql/TokenAuthenticationCloudTests.cs b/src/ServiceControl.Audit.Persistence.Tests.PostgreSql/TokenAuthenticationCloudTests.cs new file mode 100644 index 0000000000..e49a78dff2 --- /dev/null +++ b/src/ServiceControl.Audit.Persistence.Tests.PostgreSql/TokenAuthenticationCloudTests.cs @@ -0,0 +1,42 @@ +namespace ServiceControl.Audit.Persistence.Tests +{ + using System; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using Npgsql; + using NUnit.Framework; + using ServiceControl.Audit.Persistence.EFCore.Infrastructure; + using ServiceControl.Persistence.Tests; + + [TestFixture] + class TokenAuthenticationCloudTests + { + [Test] + public async Task Migrates_round_trips_locks_and_drops_the_schema_with_a_real_token() + { + var connectionString = TokenConnectionStrings.Cloud; + + if (string.IsNullOrEmpty(connectionString)) + { + Assert.Ignore($"{TokenConnectionStrings.CloudVariable} is not set. The cloud database tests set it for the azure-postgresql and aurora-postgresql targets, see docs/cloud-database-tests.md."); + } + + var schema = $"sc_token_{Guid.NewGuid():n}"; + await using var provider = TokenAuthenticationTests.BuildServiceProvider(connectionString, schema); + + try + { + await TokenAuthenticationTests.ApplyMigrations(provider); + await TokenAuthenticationTests.RoundTrip(provider); + + var retentionLock = await provider.GetRequiredService().TryAcquire(); + Assert.That(retentionLock, Is.Not.Null); + await retentionLock.DisposeAsync(); + } + finally + { + await TestSchema.Drop(provider.GetRequiredService(), schema); + } + } + } +} diff --git a/src/ServiceControl.Audit.Persistence.Tests.PostgreSql/TokenAuthenticationTests.cs b/src/ServiceControl.Audit.Persistence.Tests.PostgreSql/TokenAuthenticationTests.cs new file mode 100644 index 0000000000..31b6d4a517 --- /dev/null +++ b/src/ServiceControl.Audit.Persistence.Tests.PostgreSql/TokenAuthenticationTests.cs @@ -0,0 +1,205 @@ +namespace ServiceControl.Audit.Persistence.Tests +{ + using System; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.DependencyInjection; + using Npgsql; + using NUnit.Framework; + using ServiceControl.Audit.Persistence.EFCore.Abstractions; + using ServiceControl.Audit.Persistence.EFCore.DbContexts; + using ServiceControl.Audit.Persistence.EFCore.Entities; + using ServiceControl.Audit.Persistence.EFCore.Implementation; + using ServiceControl.Audit.Persistence.EFCore.Infrastructure; + using ServiceControl.Audit.Persistence.EFCore.PostgreSql; + using ServiceControl.Persistence.Tests; + using ServiceControl.PostgreSql.TokenAuthentication; + + [TestFixture] + class TokenAuthenticationTests + { + [TestCase("Token Authentication=AzureEntraId;Token Scope=https://ossrdbms-aad.database.windows.net/.default")] + [TestCase("Token Authentication=AwsRdsIam")] + public async Task Migrates_and_queries_with_one_token_for_many_connections(string keywords) + { + var (connectionString, password) = await TokenConnectionStrings.ForSharedContainer(keywords); + var schema = $"sc_token_{Guid.NewGuid():n}"; + var tokenProvider = new CountingTokenProvider(password); + + try + { + await using var provider = BuildServiceProvider(connectionString, schema, tokenProvider); + + await ApplyMigrations(provider); + await RoundTrip(provider); + await QueryOnConcurrentConnections(provider, connections: 3); + var hosting = await provider.GetRequiredService().Probe(); + var identity = await provider.GetRequiredService().GetIdentity(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(hosting.Source, Is.EqualTo(DatabaseHostingSource.Probe)); + Assert.That(identity?.Schema, Is.EqualTo(schema)); + Assert.That(tokenProvider.Calls, Is.EqualTo(1)); + Assert.That(tokenProvider.Username, Is.EqualTo(new NpgsqlConnectionStringBuilder(provider.GetRequiredService().ConnectionString).Username)); + } + } + finally + { + await TestSchema.Drop(await PostgreSqlSharedContainer.GetConnectionStringAsync(), schema); + } + } + + [Test] + public async Task Acquires_and_releases_the_retention_lock_on_unpooled_token_connections() + { + var (connectionString, password) = await TokenConnectionStrings.ForSharedContainer("Token Authentication=AwsRdsIam"); + var tokenProvider = new CountingTokenProvider(password); + await using var provider = BuildServiceProvider(connectionString, $"sc_token_{Guid.NewGuid():n}", tokenProvider); + var retentionLock = provider.GetRequiredService(); + + var first = await retentionLock.TryAcquire(); + var whileHeld = await retentionLock.TryAcquire(); + await first!.DisposeAsync(); + var afterRelease = await retentionLock.TryAcquire(); + await afterRelease!.DisposeAsync(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first, Is.Not.Null); + Assert.That(whileHeld, Is.Null); + Assert.That(afterRelease, Is.Not.Null); + Assert.That(new NpgsqlConnectionStringBuilder(provider.GetRequiredKeyedService(PostgreSqlRetentionLock.TokenDataSourceKey).ConnectionString).Pooling, Is.False); + Assert.That(tokenProvider.Calls, Is.EqualTo(1)); + } + } + + [Test] + public void Registers_the_data_sources_only_with_token_authentication() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(DataSourceKeys("Host=localhost;Username=sc"), Is.Empty); + Assert.That(DataSourceKeys("Host=localhost;Username=sc;Token Authentication=AwsRdsIam"), Is.EquivalentTo(new object[] { null, PostgreSqlRetentionLock.TokenDataSourceKey })); + } + } + + [Test] + public async Task Disposes_the_data_sources_with_the_container() + { + var provider = BuildServiceProvider("Host=localhost;Username=sc;Token Authentication=AwsRdsIam", tokenProvider: new CountingTokenProvider("unused")); + var dataSource = provider.GetRequiredService(); + var retentionLockDataSource = provider.GetRequiredKeyedService(PostgreSqlRetentionLock.TokenDataSourceKey); + + await provider.DisposeAsync(); + + using (Assert.EnterMultipleScope()) + { + Assert.ThrowsAsync(async () => await dataSource.OpenConnectionAsync()); + Assert.ThrowsAsync(async () => await retentionLockDataSource.OpenConnectionAsync()); + } + } + + internal static ServiceProvider BuildServiceProvider(string connectionString, string schema = null, IPostgreSqlTokenProvider tokenProvider = null) + { + var services = new ServiceCollection(); + services.AddLogging(); + var persistence = new PostgreSqlPersistenceConfiguration().Create(CreateSettings(connectionString, schema)); + persistence.AddInstaller(services); + persistence.AddPersistence(services); + + if (tokenProvider is not null) + { + services.AddSingleton(tokenProvider); + } + + return services.BuildServiceProvider(); + } + + internal static Task ApplyMigrations(IServiceProvider provider) => ActivatorUtilities.CreateInstance(provider).StartAsync(); + + internal static async Task RoundTrip(IServiceProvider provider) + { + var id = Guid.NewGuid(); + + using (var scope = provider.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.FailedAuditImports.Add(new FailedAuditImportEntity { UniqueMessageId = id, FailedAt = DateTime.UtcNow, HeadersJson = "{}", Body = [1, 2, 3] }); + await dbContext.SaveChangesAsync(); + } + + using (var scope = provider.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var stored = await dbContext.FailedAuditImports.AsNoTracking().SingleAsync(e => e.UniqueMessageId == id); + + Assert.That(stored.Body, Is.EqualTo(new byte[] { 1, 2, 3 })); + } + } + + static async Task QueryOnConcurrentConnections(IServiceProvider provider, int connections) + { + var scopes = Enumerable.Range(0, connections).Select(_ => provider.CreateAsyncScope()).ToArray(); + + try + { + foreach (var scope in scopes) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.OpenConnectionAsync(); + await dbContext.FailedAuditImports.CountAsync(); + } + } + finally + { + foreach (var scope in scopes) + { + await scope.DisposeAsync(); + } + } + } + + static object[] DataSourceKeys(string connectionString) + { + var services = new ServiceCollection(); + var persistence = new PostgreSqlPersistenceConfiguration().Create(CreateSettings(connectionString)); + persistence.AddPersistence(services); + persistence.AddInstaller(services); + + return [.. services.Where(descriptor => descriptor.ServiceType == typeof(NpgsqlDataSource)).Select(descriptor => descriptor.ServiceKey)]; + } + + static PersistenceSettings CreateSettings(string connectionString, string schema = null) + { + var settings = new PersistenceSettings(TimeSpan.FromDays(1), true, 100000); + settings.PersisterSpecificSettings[EFPersistenceConfigurationBase.ConnectionStringKey] = connectionString; + + if (schema is not null) + { + settings.PersisterSpecificSettings[EFPersistenceConfigurationBase.SchemaKey] = schema; + } + + return settings; + } + + sealed class CountingTokenProvider(string password) : IPostgreSqlTokenProvider + { + int calls; + + public int Calls => Volatile.Read(ref calls); + + public string Username { get; private set; } + + public ValueTask GetToken(NpgsqlConnectionStringBuilder settings, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref calls); + Username = settings.Username; + + return ValueTask.FromResult(password); + } + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs index 1e4269791f..e7084461ce 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs @@ -5,9 +5,14 @@ namespace ServiceControl.Persistence.EFCore.PostgreSql; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Npgsql; +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; using ServiceControl.Persistence.EFCore.Abstractions; using ServiceControl.Persistence.EFCore.DbContexts; using ServiceControl.Persistence.EFCore.Infrastructure; +using ServiceControl.PostgreSql.TokenAuthentication; class PostgreSqlPersistence(PostgreSqlPersisterSettings settings) : BasePersistence, IPersistence { @@ -44,22 +49,22 @@ void RegisterSettings(IServiceCollection services) void ConfigureDbContext(IServiceCollection services) { + if (settings.TokenAuthentication is { } tokenAuthentication) + { + services.TryAddSingleton(_ => tokenAuthentication.CreateTokenProvider()); + services.TryAddSingleton(serviceProvider => PostgreSqlTokenDataSource.Build(settings.ConnectionString, serviceProvider.GetRequiredService(), TokenLogger(serviceProvider))); + } + services.AddDbContext((serviceProvider, options) => { - options.UseNpgsql(settings.ConnectionString, npgsqlOptions => + if (settings.TokenAuthentication is null) { - npgsqlOptions.CommandTimeout(settings.CommandTimeout); - - npgsqlOptions.MigrationsHistoryTable(ServiceControlDbContext.MigrationsHistoryTableName, settings.Schema); - - if (settings.EnableRetryOnFailure) - { - npgsqlOptions.EnableRetryOnFailure( - maxRetryCount: settings.MaxRetryCount, - maxRetryDelay: TimeSpan.FromSeconds(settings.MaxRetryDelayInSeconds), - errorCodesToAdd: null); - } - }); + options.UseNpgsql(settings.ConnectionString, ConfigureNpgsql); + } + else + { + options.UseNpgsql(serviceProvider.GetRequiredService(), ConfigureNpgsql); + } if (settings.Schema is not null) { @@ -79,4 +84,21 @@ void ConfigureDbContext(IServiceCollection services) services.AddScoped(provider => provider.GetRequiredService()); } + + static ILogger TokenLogger(IServiceProvider serviceProvider) => serviceProvider.GetRequiredService().CreateLogger(typeof(PostgreSqlTokenDataSource)); + + void ConfigureNpgsql(NpgsqlDbContextOptionsBuilder npgsqlOptions) + { + npgsqlOptions.CommandTimeout(settings.CommandTimeout); + + npgsqlOptions.MigrationsHistoryTable(ServiceControlDbContext.MigrationsHistoryTableName, settings.Schema); + + if (settings.EnableRetryOnFailure) + { + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: settings.MaxRetryCount, + maxRetryDelay: TimeSpan.FromSeconds(settings.MaxRetryDelayInSeconds), + errorCodesToAdd: null); + } + } } diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersisterSettings.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersisterSettings.cs index 88cba1ef04..cd21d801cb 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersisterSettings.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersisterSettings.cs @@ -1,7 +1,22 @@ namespace ServiceControl.Persistence.EFCore.PostgreSql; using ServiceControl.Persistence.EFCore.Abstractions; +using ServiceControl.PostgreSql.TokenAuthentication; public class PostgreSqlPersisterSettings : EFPersisterSettings { + /// + /// Assigning parses and removes the token authentication keywords, so every reader gets a string Npgsql accepts. + /// + public override required string ConnectionString + { + get; + set + { + TokenAuthentication = PostgreSqlTokenAuthentication.Parse(value, EFPersistenceConfigurationBase.ConnectionStringKey, out var npgsqlConnectionString); + field = npgsqlConnectionString; + } + } + + internal PostgreSqlTokenAuthentication? TokenAuthentication { get; private set; } } diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/ServiceControl.Persistence.EFCore.PostgreSql.csproj b/src/ServiceControl.Persistence.EFCore.PostgreSql/ServiceControl.Persistence.EFCore.PostgreSql.csproj index 1f72d54fe7..38099c09ee 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/ServiceControl.Persistence.EFCore.PostgreSql.csproj +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/ServiceControl.Persistence.EFCore.PostgreSql.csproj @@ -30,6 +30,17 @@ + + + + + + + + + + + diff --git a/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs b/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs index 5bf874154a..e737c88270 100644 --- a/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs +++ b/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs @@ -6,7 +6,7 @@ namespace ServiceControl.Persistence.EFCore.Abstractions; public abstract class EFPersistenceConfigurationBase : PersistenceConfiguration, IPersistenceConfiguration { - const string ConnectionStringKey = "Database/ConnectionString"; + public const string ConnectionStringKey = "Database/ConnectionString"; internal const string SchemaKey = "Database/Schema"; internal const string CommandTimeoutKey = "Database/CommandTimeout"; const string BodyStorageTypeKey = "MessageBody/StorageType"; diff --git a/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersisterSettings.cs b/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersisterSettings.cs index 4201675811..3daf84bc88 100644 --- a/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersisterSettings.cs +++ b/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersisterSettings.cs @@ -11,7 +11,7 @@ public abstract class EFPersisterSettings : PersistenceSettings public static readonly TimeSpan DefaultSubscriptionCacheDuration = TimeSpan.FromSeconds(60); - public required string ConnectionString { get; set; } + public virtual required string ConnectionString { get; set; } /// /// The schema the persister owns, or null to use the connection or provider default. Validated on assignment diff --git a/src/ServiceControl.Persistence.EFCore/ServiceControl.Persistence.EFCore.csproj b/src/ServiceControl.Persistence.EFCore/ServiceControl.Persistence.EFCore.csproj index 9ecbf964d0..30574ffc37 100644 --- a/src/ServiceControl.Persistence.EFCore/ServiceControl.Persistence.EFCore.csproj +++ b/src/ServiceControl.Persistence.EFCore/ServiceControl.Persistence.EFCore.csproj @@ -20,6 +20,8 @@ + + diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/PostgreSqlTokenAuthenticationTests.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/PostgreSqlTokenAuthenticationTests.cs new file mode 100644 index 0000000000..3f43809c39 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.PostgreSql/PostgreSqlTokenAuthenticationTests.cs @@ -0,0 +1,205 @@ +namespace ServiceControl.Persistence.Tests.PostgreSql; + +using System; +using Npgsql; +using NUnit.Framework; +using ServiceControl.Persistence.EFCore.Abstractions; +using ServiceControl.Persistence.EFCore.PostgreSql; +using ServiceControl.PostgreSql.TokenAuthentication; + +[TestFixture] +class PostgreSqlTokenAuthenticationTests +{ + [Test] + public void Leaves_a_connection_string_without_token_keywords_untouched() + { + const string configured = "host=db.example.com; Username = sc ;Password='p;w';Application Name=\"ServiceControl\""; + + var settings = Settings(configured); + + using (Assert.EnterMultipleScope()) + { + Assert.That(settings.ConnectionString, Is.EqualTo(configured)); + Assert.That(settings.TokenAuthentication, Is.Null); + } + } + + [Test] + public void Leaves_a_malformed_connection_string_without_token_keywords_for_npgsql_to_report() + { + const string configured = "Host=db.example.com;Username"; + + var settings = Settings(configured); + + using (Assert.EnterMultipleScope()) + { + Assert.That(settings.ConnectionString, Is.EqualTo(configured)); + Assert.That(settings.TokenAuthentication, Is.Null); + } + } + + [Test] + public void Leaves_a_value_that_only_mentions_a_keyword_untouched() + { + const string configured = "Host=db.example.com; Username=sc;Password='Token Authentication=AwsRdsIam';Application Name=Token Scope"; + + var settings = Settings(configured); + + using (Assert.EnterMultipleScope()) + { + Assert.That(settings.ConnectionString, Is.EqualTo(configured)); + Assert.That(settings.TokenAuthentication, Is.Null); + } + } + + [Test] + public void Names_the_given_setting_in_errors() => + Assert.That(Assert.Throws(() => PostgreSqlTokenAuthentication.Parse("Host=db.example.com;Username=sc;Token Authentication=Kerberos", "Some/Setting", out _)).Message, + Does.StartWith("Some/Setting is invalid.")); + + [Test] + public void Removes_the_keywords_whatever_their_case() + { + var settings = Settings("Host=sc.postgres.database.azure.com;Username=sc;token authentication=azureentraid;TOKEN SCOPE=https://custom.example.com/.default"); + + var npgsql = new NpgsqlConnectionStringBuilder(settings.ConnectionString); + using (Assert.EnterMultipleScope()) + { + Assert.That(settings.ConnectionString, Does.Not.Contain("token").IgnoreCase); + Assert.That(npgsql.Host, Is.EqualTo("sc.postgres.database.azure.com")); + Assert.That(npgsql.Username, Is.EqualTo("sc")); + Assert.That(settings.TokenAuthentication, Is.EqualTo(new AzureEntraIdTokenAuthentication("https://custom.example.com/.default", null))); + } + } + + [Test] + public void Reads_quoted_values_and_keeps_the_others_intact() + { + var settings = Settings("Host=db.example.com;Username=sc;Token Authentication=\"AwsRdsIam\";Application Name=\"a;b\""); + + using (Assert.EnterMultipleScope()) + { + Assert.That(settings.TokenAuthentication, Is.InstanceOf()); + Assert.That(new NpgsqlConnectionStringBuilder(settings.ConnectionString).ApplicationName, Is.EqualTo("a;b")); + } + } + + [TestCase("Kerberos")] + [TestCase("Azure")] + [TestCase("")] + public void Rejects_an_unknown_token_authentication(string value) + { + var exception = Invalid($"Host=db.example.com;Username=sc;Token Authentication={value}"); + + Assert.That(exception.Message, Does.Contain("Token Authentication").And.Contain("AzureEntraId").And.Contain("AwsRdsIam")); + } + + [TestCase("Host=sc.postgres.database.azure.com;Username=sc;Token Scope=https://ossrdbms-aad.database.windows.net/.default")] + [TestCase("Host=db.example.com;Username=sc;Token Authentication=AwsRdsIam;Token Scope=https://ossrdbms-aad.database.windows.net/.default")] + [TestCase("Host=db.example.com;Username=sc;Token Scope=")] + public void Rejects_a_token_scope_without_azure_entra_id(string connectionString) => + Assert.That(Invalid(connectionString).Message, Does.Contain("Token Scope").And.Contain("Token Authentication=AzureEntraId")); + + [TestCase("http://ossrdbms-aad.database.windows.net/.default")] + [TestCase("ossrdbms-aad.database.windows.net/.default")] + [TestCase("")] + public void Rejects_a_token_scope_that_is_not_an_absolute_https_uri(string scope) => + Assert.That(Invalid($"Host=db.example.com;Username=sc;Token Authentication=AzureEntraId;Token Scope={scope}").Message, Does.Contain("Token Scope").And.Contain("https")); + + [TestCase("Password")] + [TestCase("PWD")] + public void Rejects_a_password_without_repeating_it(string keyword) => + Assert.That(Invalid($"Host=db.example.com;Username=sc;{keyword}=hunter2;Token Authentication=AwsRdsIam").Message, Does.Contain("Password").And.Not.Contain("hunter2")); + + [Test] + public void Rejects_a_passfile() => + Assert.That(Invalid("Host=db.example.com;Username=sc;Passfile=/home/sc/.pgpass;Token Authentication=AwsRdsIam").Message, Does.Contain("Passfile").And.Contain("Token Authentication")); + + [TestCase("AzureEntraId")] + [TestCase("AwsRdsIam")] + public void Requires_a_username(string value) => + Assert.That(Invalid($"Host=sc.postgres.database.azure.com;Token Authentication={value}").Message, Does.Contain("Username").And.Contain("Token Authentication")); + + [TestCase("Host=10.0.0.4")] + [TestCase("Host=sc.postgres.database.azure.com,db.example.com")] + [TestCase("")] + public void Requires_a_token_scope_when_the_host_names_no_known_azure_cloud(string host) => + Assert.That(Invalid($"{host};Username=sc;Token Authentication=AzureEntraId").Message, Does.Contain("Token Scope").And.Contain("Token Authentication=AzureEntraId")); + + [TestCase("")] + [TestCase(";Token Scope=https://ossrdbms-aad.database.windows.net/.default")] + public void Rejects_azure_hosts_in_different_clouds(string scope) => + Assert.That(Invalid($"Host=a.postgres.database.azure.com,b.postgres.database.usgovcloudapi.net;Username=sc;Token Authentication=AzureEntraId{scope}").Message, Does.Contain("Token Authentication=AzureEntraId").And.Contain("different Azure clouds")); + + [TestCase("Host=a.rds.amazonaws.com,b.rds.amazonaws.com;")] + [TestCase("")] + public void Requires_exactly_one_host_for_aws_rds_iam(string host) => + Assert.That(Invalid($"{host}Username=sc;Token Authentication=AwsRdsIam").Message, Does.Contain("Token Authentication=AwsRdsIam").And.Contain("one host")); + + [Test] + public void Accepts_one_aws_host_with_a_port() => + Assert.That(Settings("Host=sc.abc.eu-west-1.rds.amazonaws.com:5433;Username=sc;Token Authentication=AwsRdsIam").TokenAuthentication, Is.InstanceOf()); + + [TestCase("sc.postgres.database.azure.com", "https://ossrdbms-aad.database.windows.net/.default", null)] + [TestCase("SC.Postgres.Database.Azure.COM:5432", "https://ossrdbms-aad.database.windows.net/.default", null)] + [TestCase("sc.privatelink.postgres.database.azure.com", "https://ossrdbms-aad.database.windows.net/.default", null)] + [TestCase("sc.postgres.database.usgovcloudapi.net", "https://ossrdbms-aad.database.usgovcloudapi.net/.default", "https://login.microsoftonline.us/")] + [TestCase("sc.postgres.database.chinacloudapi.cn", "https://ossrdbms-aad.database.chinacloudapi.cn/.default", "https://login.chinacloudapi.cn/")] + [TestCase("a.postgres.database.azure.com, b.postgres.database.azure.com:6432", "https://ossrdbms-aad.database.windows.net/.default", null)] + public void Maps_the_host_to_its_azure_cloud(string host, string scope, string authorityHost) => + Assert.That(Settings($"Host={host};Username=sc;Token Authentication=AzureEntraId").TokenAuthentication, + Is.EqualTo(new AzureEntraIdTokenAuthentication(scope, authorityHost is null ? null : new Uri(authorityHost)))); + + [TestCase("https://ossrdbms-aad.database.usgovcloudapi.net/.default", "https://login.microsoftonline.us/")] + [TestCase("https://ossrdbms-aad.database.chinacloudapi.cn/.default", "https://login.chinacloudapi.cn/")] + [TestCase("https://ossrdbms-aad.database.windows.net/.default", null)] + [TestCase("https://sovereign.example/.default", null)] + public void Takes_the_authority_host_from_a_known_token_scope(string scope, string authorityHost) => + Assert.That(Settings($"Host=10.0.0.4;Username=sc;Token Authentication=AzureEntraId;Token Scope={scope}").TokenAuthentication, + Is.EqualTo(new AzureEntraIdTokenAuthentication(scope, authorityHost is null ? null : new Uri(authorityHost)))); + + [Test] + public void Prefers_the_token_scope_over_the_host() => + Assert.That(Settings("Host=sc.postgres.database.azure.com;Username=sc;Token Authentication=AzureEntraId;Token Scope=https://custom.example.com/.default").TokenAuthentication, + Is.EqualTo(new AzureEntraIdTokenAuthentication("https://custom.example.com/.default", null))); + + [TestCase("sc.postgres.database.azure.com.example.com")] + [TestCase("postgres.database.azure.com")] + public void Does_not_map_a_host_that_only_resembles_an_azure_one(string host) => + Assert.That(AzureEntraIdCloud.ForHost(host), Is.Null); + + [Test] + public void Names_the_setting_when_the_connection_string_cannot_be_parsed() => + Assert.That(Assert.Throws(() => Settings("Host=db.example.com;Username=sc;Token Authentication=AwsRdsIam;Port")).Message, Does.StartWith("Database/ConnectionString is not a valid connection string.")); + + [Test] + public void Names_the_setting_when_npgsql_rejects_an_unknown_keyword_with_an_empty_value() => + Assert.That(Assert.Throws(() => Settings("Host=db.example.com;Username=sc;Token Authentication=AwsRdsIam;Foo=''")).Message, Does.StartWith("Database/ConnectionString is not a valid connection string.")); + + [Test] + public void Does_not_repeat_a_password_swallowed_by_a_malformed_value() + { + var exception = Assert.Throws(() => Settings("Host=db.example.com;Username=sc;Port=5432 Password=hunter2;Token Authentication=AwsRdsIam")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.Message, Does.Contain("Database/ConnectionString").And.Not.Contain("hunter2")); + Assert.That(exception.InnerException, Is.Null); + } + } + + static PostgreSqlPersisterSettings Settings(string connectionString) => new() + { + ConnectionString = connectionString, + BodyStorage = new FileSystemBodyStorageSettings { StoragePath = "unused" } + }; + + static InvalidOperationException Invalid(string connectionString) + { + var exception = Assert.Throws(() => Settings(connectionString)); + + Assert.That(exception.Message, Does.StartWith("Database/ConnectionString is invalid.")); + + return exception; + } +} diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/TestSchema.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/TestSchema.cs index 23bd9cae35..0534f4c471 100644 --- a/src/ServiceControl.Persistence.Tests.PostgreSql/TestSchema.cs +++ b/src/ServiceControl.Persistence.Tests.PostgreSql/TestSchema.cs @@ -13,7 +13,15 @@ public static Task CreateTable(string connectionString, string schema, string ta Execute(connectionString, $"CREATE TABLE \"{schema}\".\"{table}\" (id int)", cancellationToken); public static Task Drop(string connectionString, string schema, CancellationToken cancellationToken = default) => - Execute(connectionString, $"DROP SCHEMA IF EXISTS \"{schema}\" CASCADE", cancellationToken); + Execute(connectionString, DropSql(schema), cancellationToken); + + public static async Task Drop(NpgsqlDataSource dataSource, string schema, CancellationToken cancellationToken = default) + { + await using var command = dataSource.CreateCommand(DropSql(schema)); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + static string DropSql(string schema) => $"DROP SCHEMA IF EXISTS \"{schema}\" CASCADE"; static async Task Execute(string connectionString, string sql, CancellationToken cancellationToken) { diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/TokenAuthenticationCloudTests.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/TokenAuthenticationCloudTests.cs new file mode 100644 index 0000000000..d46376df3e --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.PostgreSql/TokenAuthenticationCloudTests.cs @@ -0,0 +1,36 @@ +// ReSharper disable once CheckNamespace +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using NUnit.Framework; + +[TestFixture] +class TokenAuthenticationCloudTests +{ + [Test] + public async Task Migrates_round_trips_and_drops_the_schema_with_a_real_token() + { + var connectionString = TokenConnectionStrings.Cloud; + + if (string.IsNullOrEmpty(connectionString)) + { + Assert.Ignore($"{TokenConnectionStrings.CloudVariable} is not set. The cloud database tests set it for the azure-postgresql and aurora-postgresql targets, see docs/cloud-database-tests.md."); + } + + var settings = TokenAuthenticationTests.CreateSettings(connectionString, $"sc_token_{Guid.NewGuid():n}"); + await using var provider = TokenAuthenticationTests.BuildServiceProvider(settings); + + try + { + await TokenAuthenticationTests.ApplyMigrations(provider); + await TokenAuthenticationTests.RoundTrip(provider); + } + finally + { + await TestSchema.Drop(provider.GetRequiredService(), settings.Schema); + } + } +} diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/TokenAuthenticationTests.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/TokenAuthenticationTests.cs new file mode 100644 index 0000000000..7612caeaef --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.PostgreSql/TokenAuthenticationTests.cs @@ -0,0 +1,213 @@ +// ReSharper disable once CheckNamespace +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using EFCore.Entities; +using EFCore.PostgreSql; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Npgsql; +using NUnit.Framework; +using ServiceControl.Persistence.EFCore.Abstractions; +using ServiceControl.Persistence.EFCore.DbContexts; +using ServiceControl.Persistence.EFCore.Infrastructure; +using ServiceControl.PostgreSql.TokenAuthentication; + +[TestFixture] +class TokenAuthenticationTests +{ + [TestCase("Token Authentication=AzureEntraId;Token Scope=https://ossrdbms-aad.database.windows.net/.default")] + [TestCase("Token Authentication=AwsRdsIam")] + public async Task Migrates_and_queries_with_one_token_for_many_connections(string keywords) + { + var (connectionString, password) = await TokenConnectionStrings.ForSharedContainer(keywords); + var settings = CreateSettings(connectionString, $"sc_token_{Guid.NewGuid():n}"); + var tokenProvider = new CountingTokenProvider(password); + + try + { + await using var provider = BuildServiceProvider(settings, tokenProvider); + + await ApplyMigrations(provider); + await RoundTrip(provider); + await QueryOnConcurrentConnections(provider, connections: 3); + var hosting = await provider.GetRequiredService().Probe(); + var identity = await provider.GetRequiredService().GetIdentity(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(hosting.Source, Is.EqualTo(DatabaseHostingSource.Probe)); + Assert.That(identity?.Schema, Is.EqualTo(settings.Schema)); + Assert.That(tokenProvider.Calls, Is.EqualTo(1)); + Assert.That(tokenProvider.Username, Is.EqualTo(new NpgsqlConnectionStringBuilder(settings.ConnectionString).Username)); + } + } + finally + { + await TestSchema.Drop(await PostgreSqlSharedContainer.GetConnectionStringAsync(), settings.Schema); + } + } + + [Test] + public async Task Logs_a_failure_to_get_a_token() + { + var (connectionString, _) = await TokenConnectionStrings.ForSharedContainer("Token Authentication=AwsRdsIam"); + _ = PostgreSqlTokenAuthentication.Parse(connectionString, "Database/ConnectionString", out var npgsqlConnectionString); + var failure = new InvalidOperationException("No credentials"); + var logger = new RecordingLogger(); + await using var dataSource = PostgreSqlTokenDataSource.Build(npgsqlConnectionString, new FailingTokenProvider(failure), logger); + + Assert.CatchAsync(async () => await dataSource.OpenConnectionAsync()); + + Assert.That(logger.Errors, Does.Contain(failure)); + } + + [Test] + public void Registers_a_data_source_only_with_token_authentication() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(RegisteredServices("Host=localhost;Username=sc"), Has.None.EqualTo(typeof(NpgsqlDataSource))); + Assert.That(RegisteredServices("Host=localhost;Username=sc;Token Authentication=AwsRdsIam"), Has.One.EqualTo(typeof(NpgsqlDataSource))); + } + } + + [Test] + public async Task Disposes_the_data_source_with_the_container() + { + var provider = BuildServiceProvider(CreateSettings("Host=localhost;Username=sc;Token Authentication=AwsRdsIam"), new CountingTokenProvider("unused")); + var dataSource = provider.GetRequiredService(); + + await provider.DisposeAsync(); + + Assert.ThrowsAsync(async () => await dataSource.OpenConnectionAsync()); + } + + internal static PostgreSqlPersisterSettings CreateSettings(string connectionString, string schema = null) => new() + { + ConnectionString = connectionString, + Schema = schema, + BodyStorage = new FileSystemBodyStorageSettings { StoragePath = Path.GetTempPath() } + }; + + internal static ServiceProvider BuildServiceProvider(PostgreSqlPersisterSettings settings, IPostgreSqlTokenProvider tokenProvider = null) + { + var services = new ServiceCollection(); + services.AddLogging(); + var persistence = new PostgreSqlPersistenceConfiguration().Create(settings); + persistence.AddInstaller(services); + persistence.AddPersistence(services); + + if (tokenProvider is not null) + { + services.AddSingleton(tokenProvider); + } + + return services.BuildServiceProvider(); + } + + internal static async Task ApplyMigrations(IServiceProvider provider) + { + using var scope = provider.CreateScope(); + await scope.ServiceProvider.GetRequiredService().ApplyMigrations(); + } + + internal static async Task RoundTrip(IServiceProvider provider) + { + var name = $"token-{Guid.NewGuid():n}"; + + using (var scope = provider.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.EndpointSettings.Add(new EndpointSettingsEntity { Name = name, TrackInstances = true }); + await dbContext.SaveChangesAsync(); + } + + using (var scope = provider.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var stored = await dbContext.EndpointSettings.AsNoTracking().SingleAsync(e => e.Name == name); + + Assert.That(stored.TrackInstances, Is.True); + } + } + + static async Task QueryOnConcurrentConnections(IServiceProvider provider, int connections) + { + var scopes = Enumerable.Range(0, connections).Select(_ => provider.CreateAsyncScope()).ToArray(); + + try + { + foreach (var scope in scopes) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.OpenConnectionAsync(); + await dbContext.EndpointSettings.CountAsync(); + } + } + finally + { + foreach (var scope in scopes) + { + await scope.DisposeAsync(); + } + } + } + + static Type[] RegisteredServices(string connectionString) + { + var services = new ServiceCollection(); + var persistence = new PostgreSqlPersistenceConfiguration().Create(CreateSettings(connectionString)); + persistence.AddPersistence(services); + persistence.AddInstaller(services); + + return [.. services.Select(descriptor => descriptor.ServiceType)]; + } + + sealed class CountingTokenProvider(string password) : IPostgreSqlTokenProvider + { + int calls; + + public int Calls => Volatile.Read(ref calls); + + public string Username { get; private set; } + + public ValueTask GetToken(NpgsqlConnectionStringBuilder settings, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref calls); + Username = settings.Username; + + return ValueTask.FromResult(password); + } + } + + sealed class FailingTokenProvider(Exception failure) : IPostgreSqlTokenProvider + { + public ValueTask GetToken(NpgsqlConnectionStringBuilder settings, CancellationToken cancellationToken = default) => ValueTask.FromException(failure); + } + + sealed class RecordingLogger : ILogger + { + readonly ConcurrentQueue errors = new(); + + public Exception[] Errors => [.. errors]; + + public IDisposable BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + if (logLevel == LogLevel.Error) + { + errors.Enqueue(exception); + } + } + } +} diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/TokenConnectionStrings.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/TokenConnectionStrings.cs new file mode 100644 index 0000000000..778bce3713 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.PostgreSql/TokenConnectionStrings.cs @@ -0,0 +1,22 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +static class TokenConnectionStrings +{ + public const string CloudVariable = "ServiceControl_Persistence_PostgreSql_TokenConnectionString"; + + public static string Cloud => Environment.GetEnvironmentVariable(CloudVariable); + + public static async Task<(string ConnectionString, string Password)> ForSharedContainer(string keywords, CancellationToken cancellationToken = default) + { + var builder = new NpgsqlConnectionStringBuilder(await PostgreSqlSharedContainer.GetConnectionStringAsync(cancellationToken)); + var password = builder.Password ?? string.Empty; + builder.Remove("Password"); + + return ($"{builder.ConnectionString};{keywords}", password); + } +} diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/EntraIdAuthenticationTests.cs b/src/ServiceControl.Persistence.Tests.SqlServer/EntraIdAuthenticationTests.cs new file mode 100644 index 0000000000..f5b5732eff --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.SqlServer/EntraIdAuthenticationTests.cs @@ -0,0 +1,68 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using EFCore.SqlServer; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using ServiceControl.Persistence.EFCore.Abstractions; + +[TestFixture] +class EntraIdAuthenticationTests +{ + const string ConnectionStringVariable = "ServiceControl_Persistence_SqlServer_TokenConnectionString"; + + static string connectionString; + + [OneTimeSetUp] + public static void RequireTokenConnectionString() + { + connectionString = Environment.GetEnvironmentVariable(ConnectionStringVariable); + + if (string.IsNullOrEmpty(connectionString)) + { + Assert.Ignore($"{ConnectionStringVariable} is not set. The cloud database tests set it for the azure-sqlserver target, see docs/cloud-database-tests.md."); + } + } + + [Test] + public async Task Migrations_and_a_round_trip_work_over_an_Entra_ID_login() + { + var settings = new SqlServerPersisterSettings + { + ConnectionString = connectionString, + Schema = $"sc_token_{Guid.NewGuid():n}", + BodyStorage = new FileSystemBodyStorageSettings { StoragePath = Path.GetTempPath() } + }; + + var services = new ServiceCollection(); + services.AddLogging(); + + var persistence = new SqlServerPersistenceConfiguration().Create(settings); + persistence.AddPersistence(services); + persistence.AddInstaller(services); + + await using var provider = services.BuildServiceProvider(); + + try + { + using (var scope = provider.CreateScope()) + { + await scope.ServiceProvider.GetRequiredService().ApplyMigrations(); + } + + var store = provider.GetRequiredService(); + await store.UpdateEndpointSettings(new EndpointSettings { Name = "Sales", TrackInstances = true }); + + var stored = await store.GetAllEndpointSettings().ToListAsync(); + + Assert.That(stored.Select(s => (s.Name, s.TrackInstances)), Is.EqualTo(new[] { ("Sales", true) })); + } + finally + { + await TestSchema.Drop(connectionString, settings.Schema); + } + } +} diff --git a/src/ServiceControl.PostgreSql.TokenAuthentication/.editorconfig b/src/ServiceControl.PostgreSql.TokenAuthentication/.editorconfig new file mode 100644 index 0000000000..ff993b49bb --- /dev/null +++ b/src/ServiceControl.PostgreSql.TokenAuthentication/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# Justification: ServiceControl app has no synchronization context +dotnet_diagnostic.CA2007.severity = none diff --git a/src/ServiceControl.PostgreSql.TokenAuthentication/AwsRdsIamTokenProvider.cs b/src/ServiceControl.PostgreSql.TokenAuthentication/AwsRdsIamTokenProvider.cs new file mode 100644 index 0000000000..617e339db8 --- /dev/null +++ b/src/ServiceControl.PostgreSql.TokenAuthentication/AwsRdsIamTokenProvider.cs @@ -0,0 +1,20 @@ +#nullable enable +namespace ServiceControl.PostgreSql.TokenAuthentication; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Amazon.RDS.Util; +using Amazon.Runtime; +using Npgsql; + +class AwsRdsIamTokenProvider : IPostgreSqlTokenProvider +{ + public async ValueTask GetToken(NpgsqlConnectionStringBuilder settings, CancellationToken cancellationToken = default) + { + var region = FallbackRegionFactory.GetRegionEndpoint() + ?? throw new InvalidOperationException($"{PostgreSqlTokenAuthentication.AuthenticationKeyword}={PostgreSqlTokenAuthentication.AwsRdsIam} could not find the AWS region of the database server. Set the AWS_REGION environment variable, or a region in the AWS profile of the account ServiceControl runs as."); + + return await RDSAuthTokenGenerator.GenerateAuthTokenAsync(region, settings.Host, settings.Port, settings.Username); + } +} diff --git a/src/ServiceControl.PostgreSql.TokenAuthentication/AzureEntraIdCloud.cs b/src/ServiceControl.PostgreSql.TokenAuthentication/AzureEntraIdCloud.cs new file mode 100644 index 0000000000..6bc4d1108c --- /dev/null +++ b/src/ServiceControl.PostgreSql.TokenAuthentication/AzureEntraIdCloud.cs @@ -0,0 +1,24 @@ +#nullable enable +namespace ServiceControl.PostgreSql.TokenAuthentication; + +using System; +using System.Linq; +using Azure.Identity; + +/// +/// The scopes are ossrdbms_resource_id in the Azure CLI's cloud definitions. A null authority host leaves at its default, which honors AZURE_AUTHORITY_HOST. +/// +sealed record AzureEntraIdCloud(string HostSuffix, string Scope, Uri? AuthorityHost) +{ + public static readonly AzureEntraIdCloud Public = new(".postgres.database.azure.com", "https://ossrdbms-aad.database.windows.net/.default", null); + public static readonly AzureEntraIdCloud Government = new(".postgres.database.usgovcloudapi.net", "https://ossrdbms-aad.database.usgovcloudapi.net/.default", AzureAuthorityHosts.AzureGovernment); + public static readonly AzureEntraIdCloud China = new(".postgres.database.chinacloudapi.cn", "https://ossrdbms-aad.database.chinacloudapi.cn/.default", AzureAuthorityHosts.AzureChina); + + static readonly AzureEntraIdCloud[] all = [Public, Government, China]; + + public static AzureEntraIdCloud? ForHost(string host) => + all.FirstOrDefault(cloud => host.EndsWith(cloud.HostSuffix, StringComparison.OrdinalIgnoreCase)); + + public static AzureEntraIdCloud? ForScope(string scope) => + all.FirstOrDefault(cloud => string.Equals(cloud.Scope, scope, StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/ServiceControl.PostgreSql.TokenAuthentication/AzureEntraIdTokenProvider.cs b/src/ServiceControl.PostgreSql.TokenAuthentication/AzureEntraIdTokenProvider.cs new file mode 100644 index 0000000000..4eec3fb2b9 --- /dev/null +++ b/src/ServiceControl.PostgreSql.TokenAuthentication/AzureEntraIdTokenProvider.cs @@ -0,0 +1,30 @@ +#nullable enable +namespace ServiceControl.PostgreSql.TokenAuthentication; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Identity; +using Npgsql; + +class AzureEntraIdTokenProvider(AzureEntraIdTokenAuthentication authentication) : IPostgreSqlTokenProvider +{ + readonly TokenCredential credential = CreateCredential(authentication.AuthorityHost); + readonly TokenRequestContext tokenRequest = new([authentication.Scope]); + + public async ValueTask GetToken(NpgsqlConnectionStringBuilder settings, CancellationToken cancellationToken = default) => + (await credential.GetTokenAsync(tokenRequest, cancellationToken)).Token; + + static DefaultAzureCredential CreateCredential(Uri? authorityHost) + { + var options = new DefaultAzureCredentialOptions(); + + if (authorityHost is not null) + { + options.AuthorityHost = authorityHost; + } + + return new DefaultAzureCredential(options); + } +} diff --git a/src/ServiceControl.PostgreSql.TokenAuthentication/IPostgreSqlTokenProvider.cs b/src/ServiceControl.PostgreSql.TokenAuthentication/IPostgreSqlTokenProvider.cs new file mode 100644 index 0000000000..a4ae522120 --- /dev/null +++ b/src/ServiceControl.PostgreSql.TokenAuthentication/IPostgreSqlTokenProvider.cs @@ -0,0 +1,11 @@ +#nullable enable +namespace ServiceControl.PostgreSql.TokenAuthentication; + +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +interface IPostgreSqlTokenProvider +{ + ValueTask GetToken(NpgsqlConnectionStringBuilder settings, CancellationToken cancellationToken = default); +} diff --git a/src/ServiceControl.PostgreSql.TokenAuthentication/PostgreSqlTokenAuthentication.cs b/src/ServiceControl.PostgreSql.TokenAuthentication/PostgreSqlTokenAuthentication.cs new file mode 100644 index 0000000000..1dd6942b89 --- /dev/null +++ b/src/ServiceControl.PostgreSql.TokenAuthentication/PostgreSqlTokenAuthentication.cs @@ -0,0 +1,184 @@ +#nullable enable +namespace ServiceControl.PostgreSql.TokenAuthentication; + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using Npgsql; + +abstract record PostgreSqlTokenAuthentication +{ + public const string AuthenticationKeyword = "Token Authentication"; + public const string ScopeKeyword = "Token Scope"; + public const string AzureEntraId = "AzureEntraId"; + public const string AwsRdsIam = "AwsRdsIam"; + + public abstract IPostgreSqlTokenProvider CreateTokenProvider(); + + /// + /// Returns null, and the connection string as it is, when neither keyword appears in it. Otherwise removes the keywords and validates them. + /// + /// Names the connection string in error messages. + public static PostgreSqlTokenAuthentication? Parse(string connectionString, string setting, out string npgsqlConnectionString) + { + npgsqlConnectionString = connectionString; + + if (!connectionString.Contains(AuthenticationKeyword, StringComparison.OrdinalIgnoreCase) && !connectionString.Contains(ScopeKeyword, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var builder = CreateBuilder(connectionString, setting); + var hasAuthentication = builder.TryGetValue(AuthenticationKeyword, out var authentication) || builder.EmptyKeywords.Contains(AuthenticationKeyword); + var hasScope = builder.TryGetValue(ScopeKeyword, out var scope) || builder.EmptyKeywords.Contains(ScopeKeyword); + + if (!hasAuthentication && !hasScope) + { + return null; + } + + builder.Remove(AuthenticationKeyword); + builder.Remove(ScopeKeyword); + npgsqlConnectionString = builder.ConnectionString; + + var isAzure = hasAuthentication && IsAzureEntraId(authentication as string, setting); + + if (hasScope && !isAzure) + { + throw Invalid(setting, $"{ScopeKeyword} is only valid together with {AuthenticationKeyword}={AzureEntraId}."); + } + + var scopeOverride = hasScope ? ReadScope(scope as string, setting) : null; + var npgsql = CreateNpgsqlBuilder(npgsqlConnectionString, setting); + + if (npgsql.Password is not null) + { + throw Invalid(setting, $"Password cannot be combined with {AuthenticationKeyword}, which supplies the token as the password. Remove Password."); + } + + if (npgsql.Passfile is not null) + { + throw Invalid(setting, $"Passfile cannot be combined with {AuthenticationKeyword}, which supplies the token as the password. Remove Passfile."); + } + + if (string.IsNullOrWhiteSpace(npgsql.Username)) + { + throw Invalid(setting, $"{AuthenticationKeyword} requires Username, the database role the token signs in as."); + } + + var hosts = HostNames(npgsql.Host); + + return isAzure ? ForAzure(hosts, scopeOverride, setting) : ForAws(hosts, setting); + } + + static bool IsAzureEntraId(string? authentication, string setting) + { + if (string.Equals(authentication, AzureEntraId, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (string.Equals(authentication, AwsRdsIam, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + throw Invalid(setting, $"{AuthenticationKeyword} has an unknown value. Valid values: {AzureEntraId}, {AwsRdsIam}."); + } + + static string ReadScope(string? scope, string setting) => + Uri.TryCreate(scope, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps + ? scope! + : throw Invalid(setting, $"{ScopeKeyword} must be an absolute https URI."); + + static AzureEntraIdTokenAuthentication ForAzure(string[] hosts, string? scopeOverride, string setting) + { + var clouds = hosts.Select(AzureEntraIdCloud.ForHost).ToArray(); + + if (clouds.OfType().Distinct().Skip(1).Any()) + { + throw Invalid(setting, $"{AuthenticationKeyword}={AzureEntraId} lists hosts in different Azure clouds, and one token cannot sign in to all of them."); + } + + if (scopeOverride is not null) + { + return new AzureEntraIdTokenAuthentication(scopeOverride, AzureEntraIdCloud.ForScope(scopeOverride)?.AuthorityHost); + } + + if (clouds.Length == 0 || clouds.Any(cloud => cloud is null)) + { + throw Invalid(setting, $"{AuthenticationKeyword}={AzureEntraId} cannot tell the Azure cloud from the host name. Add {ScopeKeyword} with the Entra ID scope of the server."); + } + + return new AzureEntraIdTokenAuthentication(clouds[0]!.Scope, clouds[0]!.AuthorityHost); + } + + static AwsRdsIamTokenAuthentication ForAws(string[] hosts, string setting) => + hosts.Length == 1 + ? new AwsRdsIamTokenAuthentication() + : throw Invalid(setting, $"{AuthenticationKeyword}={AwsRdsIam} requires exactly one host, because an IAM token is signed for one host and port."); + + static string[] HostNames(string? host) => + (host ?? string.Empty) + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(WithoutPort) + .ToArray(); + + static string WithoutPort(string host) + { + var portSeparator = host.LastIndexOf(':'); + + return portSeparator > 0 && host.IndexOf(':') == portSeparator ? host[..portSeparator] : host; + } + + static KeywordBuilder CreateBuilder(string connectionString, string setting) + { + try + { + return new KeywordBuilder { ConnectionString = connectionString }; + } + catch (ArgumentException e) + { + throw NotAConnectionString(setting, e); + } + } + + static NpgsqlConnectionStringBuilder CreateNpgsqlBuilder(string connectionString, string setting) + { + try + { + return new NpgsqlConnectionStringBuilder(connectionString); + } + catch (Exception e) when (e is ArgumentException or KeyNotFoundException) + { + throw NotAConnectionString(setting, e); + } + } + + static InvalidOperationException NotAConnectionString(string setting, Exception e) => new($"{setting} is not a valid connection string. {e.Message}"); + + static InvalidOperationException Invalid(string setting, string reason) => new($"{setting} is invalid. {reason}"); + + sealed class KeywordBuilder : DbConnectionStringBuilder + { + public HashSet EmptyKeywords { get; } = new(StringComparer.OrdinalIgnoreCase); + + // Setting ConnectionString calls Remove, and stores nothing, for a keyword written with an empty value. + public override bool Remove(string keyword) + { + EmptyKeywords.Add(keyword); + return base.Remove(keyword); + } + } +} + +sealed record AzureEntraIdTokenAuthentication(string Scope, Uri? AuthorityHost) : PostgreSqlTokenAuthentication +{ + public override IPostgreSqlTokenProvider CreateTokenProvider() => new AzureEntraIdTokenProvider(this); +} + +sealed record AwsRdsIamTokenAuthentication : PostgreSqlTokenAuthentication +{ + public override IPostgreSqlTokenProvider CreateTokenProvider() => new AwsRdsIamTokenProvider(); +} diff --git a/src/ServiceControl.PostgreSql.TokenAuthentication/PostgreSqlTokenDataSource.cs b/src/ServiceControl.PostgreSql.TokenAuthentication/PostgreSqlTokenDataSource.cs new file mode 100644 index 0000000000..bb59eb90d7 --- /dev/null +++ b/src/ServiceControl.PostgreSql.TokenAuthentication/PostgreSqlTokenDataSource.cs @@ -0,0 +1,33 @@ +#nullable enable +namespace ServiceControl.PostgreSql.TokenAuthentication; + +using System; +using Microsoft.Extensions.Logging; +using Npgsql; + +static class PostgreSqlTokenDataSource +{ + public static NpgsqlDataSource Build(string connectionString, IPostgreSqlTokenProvider tokenProvider, ILogger logger) => + new NpgsqlDataSourceBuilder(connectionString) + .UsePeriodicPasswordProvider(async (settings, cancellationToken) => + { + try + { + return await tokenProvider.GetToken(settings, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception e) + { + logger.LogError(e, "Could not get a database access token. New connections keep using the previous token, if there is one, until it expires. The next attempt is in {RetryInterval}", RetryInterval); + throw; + } + }, RefreshInterval, RetryInterval) + .Build(); + + // Well inside the 15-minute lifetime of an AWS token, and few enough calls for AzureCliCredential, which starts a process each time. + static readonly TimeSpan RefreshInterval = TimeSpan.FromMinutes(5); + static readonly TimeSpan RetryInterval = TimeSpan.FromSeconds(10); +} diff --git a/src/ServiceControl.Transports.PostgreSql.Tests/PostgreSqlTokenDataSourcesTests.cs b/src/ServiceControl.Transports.PostgreSql.Tests/PostgreSqlTokenDataSourcesTests.cs new file mode 100644 index 0000000000..326605c4db --- /dev/null +++ b/src/ServiceControl.Transports.PostgreSql.Tests/PostgreSqlTokenDataSourcesTests.cs @@ -0,0 +1,51 @@ +namespace ServiceControl.Transport.Tests; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using NUnit.Framework; +using ServiceControl.PostgreSql.TokenAuthentication; +using Transports.PostgreSql; + +[TestFixture] +class PostgreSqlTokenDataSourcesTests +{ + [Test] + public void Leaves_a_password_connection_string_to_the_transport() + { + const string configured = "Host=localhost; Username=sc;Password='p;w'"; + var dataSources = new PostgreSqlTokenDataSources(_ => throw new InvalidOperationException("No token provider is needed.")); + + var dataSource = dataSources.GetOrCreate(configured, "Setting", out var npgsqlConnectionString); + + using (Assert.EnterMultipleScope()) + { + Assert.That(dataSource, Is.Null); + Assert.That(npgsqlConnectionString, Is.EqualTo(configured)); + } + } + + [Test] + public async Task Shares_one_data_source_per_connection_string() + { + var dataSources = new PostgreSqlTokenDataSources(_ => new FixedTokenProvider()); + + await using var first = dataSources.GetOrCreate("Host=a.example.com;Username=sc;Token Authentication=AwsRdsIam", "Setting", out var npgsqlConnectionString); + var again = dataSources.GetOrCreate("server = a.example.com; USER ID=sc; token authentication=awsrdsiam", "Setting", out _); + await using var other = dataSources.GetOrCreate("Host=b.example.com;Username=sc;Token Authentication=AwsRdsIam", "Setting", out _); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first, Is.Not.Null); + Assert.That(again, Is.SameAs(first)); + Assert.That(other, Is.Not.SameAs(first)); + Assert.That(npgsqlConnectionString, Does.Not.Contain("Token").IgnoreCase); + } + } + + sealed class FixedTokenProvider : IPostgreSqlTokenProvider + { + public ValueTask GetToken(NpgsqlConnectionStringBuilder settings, CancellationToken cancellationToken = default) => ValueTask.FromResult("token"); + } +} diff --git a/src/ServiceControl.Transports.PostgreSql.Tests/TokenAuthenticationTests.cs b/src/ServiceControl.Transports.PostgreSql.Tests/TokenAuthenticationTests.cs new file mode 100644 index 0000000000..06c17148c8 --- /dev/null +++ b/src/ServiceControl.Transports.PostgreSql.Tests/TokenAuthenticationTests.cs @@ -0,0 +1,192 @@ +namespace ServiceControl.Transport.Tests; + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using NServiceBus.Transport; +using NUnit.Framework; +using ServiceControl.PostgreSql.TokenAuthentication; +using Transports; +using Transports.BrokerThroughput; +using Transports.PostgreSql; + +[TestFixture] +class TokenAuthenticationTests : TransportTestFixture +{ + CountingTokenProvider tokenProvider; + string username; + + public override async Task Setup() + { + await base.Setup(); + + var builder = new NpgsqlConnectionStringBuilder(configuration.ConnectionString); + tokenProvider = new CountingTokenProvider(builder.Password); + username = builder.Username; + builder.Remove("Password"); + + configuration.UseTokenAuthentication($"{builder.ConnectionString};Token Authentication=AwsRdsIam", tokenProvider); + } + + [Test] + public async Task Provisions_sends_and_receives_with_one_token() + { + var queueName = GetTestQueueName("token"); + await CreateTestQueue(queueName); + var received = CreateTaskCompletionSource(); + + await StartQueueIngestor( + queueName, + (_, __) => + { + received.TrySetResult(true); + return Task.CompletedTask; + }, + (_, __) => + { + Assert.Fail("There should be no errors"); + return Task.FromResult(ErrorHandleResult.Handled); + }); + + var dispatcher = await configuration.TransportCustomization.CreateTransportInfrastructure("TokenDispatcher", Settings("TokenDispatcher")); + + try + { + await dispatcher.Dispatcher.SendTestMessage(queueName, "some content", configuration.TransportCustomization); + + Assert.That(await received.Task, Is.True); + } + finally + { + await dispatcher.Shutdown(); + } + + using (Assert.EnterMultipleScope()) + { + Assert.That(tokenProvider.Calls, Is.EqualTo(1)); + Assert.That(tokenProvider.Username, Is.EqualTo(username)); + } + } + + [Test] + public async Task Reports_queue_length_with_a_token() + { + var queueName = GetTestQueueName("tokenlength"); + await CreateTestQueue(queueName); + var reported = CreateTaskCompletionSource(); + + await using var scope = await StartQueueLengthProvider(queueName, entry => + { + if (entry.Value > 0) + { + reported.TrySetResult(entry); + } + }); + + await Dispatcher.SendTestMessage(queueName, "some content", configuration.TransportCustomization); + + using (Assert.EnterMultipleScope()) + { + Assert.That((await reported.Task).Value, Is.EqualTo(1)); + Assert.That(tokenProvider.Calls, Is.EqualTo(1)); + } + } + + [TestCase(false)] + [TestCase(true)] + public async Task Queries_throughput_with_a_token(bool setThroughputConnectionString) + { + var queueName = GetTestQueueName("tokenthroughput"); + await CreateTestQueue(queueName); + var query = CreateThroughputQuery(queueName); + var settings = new Dictionary(); + + if (setThroughputConnectionString) + { + settings[PostgreSqlQuery.PostgreSqlSettings.ConnectionString] = configuration.ConnectionString; + } + + query.Initialize(new ReadOnlyDictionary(settings)); + var (success, errors, _) = await query.TestConnection(); + var queueNames = new List(); + + await foreach (var queue in query.GetQueueNames()) + { + queueNames.Add(queue.SanitizedName); + } + + using (Assert.EnterMultipleScope()) + { + Assert.That(success, Is.True, string.Join(Environment.NewLine, errors)); + Assert.That(queueNames, Does.Contain(queueName)); + Assert.That(tokenProvider.Calls, Is.EqualTo(1)); + } + } + + [Test] + public async Task Names_the_throughput_setting_when_its_token_keywords_are_invalid() + { + var query = CreateThroughputQuery(GetTestQueueName("tokeninvalid")); + + query.Initialize(new ReadOnlyDictionary(new Dictionary + { + [PostgreSqlQuery.PostgreSqlSettings.ConnectionString] = "Host=localhost;Token Authentication=AwsRdsIam" + })); + var (success, errors, _) = await query.TestConnection(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(success, Is.False); + Assert.That(errors, Has.One.StartsWith("LicensingComponent/PostgreSQL/ConnectionString is invalid.").And.Contain("Username")); + } + } + + [Test] + public void Names_the_transport_connection_string_when_its_token_keywords_are_invalid() + { + var settings = Settings(GetTestQueueName("tokeninvalid")); + settings.ConnectionString = "Host=localhost;Username=sc;Token Authentication=Kerberos"; + + var exception = Assert.ThrowsAsync(() => configuration.TransportCustomization.ProvisionQueues(settings, [])); + + Assert.That(exception.Message, Does.StartWith("The transport connection string is invalid.").And.Contain("AwsRdsIam")); + } + + IBrokerThroughputQuery CreateThroughputQuery(string endpointName) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(TimeProvider.System); + configuration.TransportCustomization.AddTransportForPrimary(services, Settings(endpointName)); + + return services.BuildServiceProvider().GetRequiredService(); + } + + TransportSettings Settings(string endpointName) => new() + { + ConnectionString = configuration.ConnectionString, + EndpointName = endpointName, + MaxConcurrency = 1 + }; + + sealed class CountingTokenProvider(string password) : IPostgreSqlTokenProvider + { + int calls; + + public int Calls => Volatile.Read(ref calls); + + public string Username { get; private set; } + + public ValueTask GetToken(NpgsqlConnectionStringBuilder settings, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref calls); + Username = settings.Username; + + return ValueTask.FromResult(password); + } + } +} diff --git a/src/ServiceControl.Transports.PostgreSql.Tests/TransportTestsConfiguration.cs b/src/ServiceControl.Transports.PostgreSql.Tests/TransportTestsConfiguration.cs index 12e0d3b091..4b5b579cb6 100644 --- a/src/ServiceControl.Transports.PostgreSql.Tests/TransportTestsConfiguration.cs +++ b/src/ServiceControl.Transports.PostgreSql.Tests/TransportTestsConfiguration.cs @@ -3,6 +3,7 @@ using System; using System.Threading; using System.Threading.Tasks; + using ServiceControl.PostgreSql.TokenAuthentication; using ServiceControl.Transports.PostgreSql; using Transports; @@ -27,6 +28,12 @@ public Task Configure(CancellationToken cancellationToken = default) public Task Cleanup(CancellationToken cancellationToken = default) => Task.CompletedTask; + public void UseTokenAuthentication(string connectionString, IPostgreSqlTokenProvider tokenProvider) + { + ConnectionString = connectionString; + TransportCustomization = new PostgreSqlTransportCustomization { TokenDataSources = new(_ => tokenProvider) }; + } + const string ConnectionStringKey = "ServiceControl_TransportTests_PostgreSQL_ConnectionString"; } } \ No newline at end of file diff --git a/src/ServiceControl.Transports.PostgreSql/DatabaseDetails.cs b/src/ServiceControl.Transports.PostgreSql/DatabaseDetails.cs index cffabb03cd..7b3678900d 100644 --- a/src/ServiceControl.Transports.PostgreSql/DatabaseDetails.cs +++ b/src/ServiceControl.Transports.PostgreSql/DatabaseDetails.cs @@ -10,11 +10,14 @@ public class DatabaseDetails { readonly string connectionString; + readonly NpgsqlDataSource tokenDataSource; public string DatabaseName { get; } - public DatabaseDetails(string connectionString) + public DatabaseDetails(string connectionString, NpgsqlDataSource tokenDataSource = null) { + this.tokenDataSource = tokenDataSource; + try { var builder = new NpgsqlConnectionStringBuilder @@ -122,7 +125,7 @@ public async Task GetSnapshot(BrokerQueueTable brokerQ async Task OpenConnectionAsync(CancellationToken cancellationToken) { - var conn = new NpgsqlConnection(connectionString); + var conn = tokenDataSource?.CreateConnection() ?? new NpgsqlConnection(connectionString); await conn.OpenAsync(cancellationToken); return conn; } diff --git a/src/ServiceControl.Transports.PostgreSql/PostgreSqlQuery.cs b/src/ServiceControl.Transports.PostgreSql/PostgreSqlQuery.cs index cc1e55da63..468fb489d2 100644 --- a/src/ServiceControl.Transports.PostgreSql/PostgreSqlQuery.cs +++ b/src/ServiceControl.Transports.PostgreSql/PostgreSqlQuery.cs @@ -15,15 +15,25 @@ public class PostgreSqlQuery( TimeProvider timeProvider, TransportSettings transportSettings) : BrokerThroughputQuery(logger, "PostgreSql") { + internal PostgreSqlQuery( + ILogger logger, + TimeProvider timeProvider, + TransportSettings transportSettings, + PostgreSqlTokenDataSources tokenDataSources) : this(logger, timeProvider, transportSettings) => this.tokenDataSources = tokenDataSources; + readonly List databases = []; + readonly PostgreSqlTokenDataSources tokenDataSources = new(); protected override void InitializeCore(ReadOnlyDictionary settings) { + var setting = $"LicensingComponent/{PostgreSqlSettings.ConnectionString}"; + if (!settings.TryGetValue(PostgreSqlSettings.ConnectionString, out string? connectionString)) { logger.LogInformation("Using ConnectionString used by instance"); connectionString = transportSettings.ConnectionString.RemoveCustomConnectionStringParts(out string _, out string _); + setting = PostgreSqlTransportCustomization.TransportConnectionStringSetting; Diagnostics.AppendLine("ConnectionString not set, defaulted to using ConnectionString used by instance"); } @@ -32,7 +42,9 @@ protected override void InitializeCore(ReadOnlyDictionary settin Diagnostics.AppendLine("ConnectionString set"); } - databases.Add(new DatabaseDetails(connectionString)); + var tokenDataSource = tokenDataSources.GetOrCreate(connectionString, setting, out var npgsqlConnectionString); + + databases.Add(new DatabaseDetails(npgsqlConnectionString, tokenDataSource)); } public override async IAsyncEnumerable GetThroughputPerDay(IBrokerQueue brokerQueue, diff --git a/src/ServiceControl.Transports.PostgreSql/PostgreSqlTokenDataSources.cs b/src/ServiceControl.Transports.PostgreSql/PostgreSqlTokenDataSources.cs new file mode 100644 index 0000000000..eb322b2aa1 --- /dev/null +++ b/src/ServiceControl.Transports.PostgreSql/PostgreSqlTokenDataSources.cs @@ -0,0 +1,38 @@ +#nullable enable +namespace ServiceControl.Transports.PostgreSql; + +using System; +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using Npgsql; +using ServiceControl.Infrastructure; +using ServiceControl.PostgreSql.TokenAuthentication; + +/// +/// Shares one token data source per connection string, so the endpoints, the queue length provider and the throughput query of an instance refresh one token. +/// +sealed class PostgreSqlTokenDataSources(Func createTokenProvider) +{ + public PostgreSqlTokenDataSources() : this(authentication => authentication.CreateTokenProvider()) + { + } + + public NpgsqlDataSource? GetOrCreate(string connectionString, string setting, out string npgsqlConnectionString) + { + var authentication = PostgreSqlTokenAuthentication.Parse(connectionString, setting, out npgsqlConnectionString); + + if (authentication is null) + { + return null; + } + + var canonicalConnectionString = new NpgsqlConnectionStringBuilder(npgsqlConnectionString).ConnectionString; + + return dataSources.GetOrAdd((canonicalConnectionString, authentication), key => new Lazy(() => PostgreSqlTokenDataSource.Build(key.NpgsqlConnectionString, createTokenProvider(key.Authentication), logger))).Value; + } + + // Npgsql's canonical form, so one connection written two ways, such as in the transport connection string and in PostgreSQL/ConnectionString, shares a data source. + readonly ConcurrentDictionary<(string NpgsqlConnectionString, PostgreSqlTokenAuthentication Authentication), Lazy> dataSources = new(); + + static readonly ILogger logger = LoggerUtil.CreateStaticLogger(typeof(PostgreSqlTokenDataSource)); +} diff --git a/src/ServiceControl.Transports.PostgreSql/PostgreSqlTransportCustomization.cs b/src/ServiceControl.Transports.PostgreSql/PostgreSqlTransportCustomization.cs index 063c32b3e4..b651cea318 100644 --- a/src/ServiceControl.Transports.PostgreSql/PostgreSqlTransportCustomization.cs +++ b/src/ServiceControl.Transports.PostgreSql/PostgreSqlTransportCustomization.cs @@ -1,5 +1,6 @@ namespace ServiceControl.Transports.PostgreSql; +using System; using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; @@ -12,6 +13,10 @@ public class PostgreSqlTransportCustomization() : TransportCustomization { + internal const string TransportConnectionStringSetting = "The transport connection string"; + + internal PostgreSqlTokenDataSources TokenDataSources { get; init; } = new(); + protected override void CustomizeTransportForPrimaryEndpoint(EndpointConfiguration endpointConfiguration, PostgreSqlTransport transportDefinition, TransportSettings transportSettings) { transportDefinition.TransportTransactionMode = TransportTransactionMode.SendsAtomicWithReceive; @@ -35,10 +40,15 @@ protected override void CustomizeTransportForMonitoringEndpoint(EndpointConfigur protected override void AddTransportForPrimaryCore(IServiceCollection services, TransportSettings transportSettings) => - services.AddSingleton(); + services.AddSingleton(provider => new PostgreSqlQuery( + provider.GetRequiredService>(), + provider.GetRequiredService(), + transportSettings, + TokenDataSources)); protected override void AddTransportForMonitoringCore(IServiceCollection services, TransportSettings transportSettings) { + services.AddSingleton(TokenDataSources); services.AddSingleton(); services.AddHostedService(provider => provider.GetRequiredService()); } @@ -60,8 +70,11 @@ public override IEnumerable GetEnvironmentData(Transp protected override PostgreSqlTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly) { var connectionString = transportSettings.ConnectionString.RemoveCustomConnectionStringParts(out var customSchema, out var subscriptionsTableSetting); + var tokenDataSource = TokenDataSources.GetOrCreate(connectionString, TransportConnectionStringSetting, out _); - var transport = new PostgreSqlTransport(connectionString); + var transport = tokenDataSource is null + ? new PostgreSqlTransport(connectionString) + : new PostgreSqlTransport(cancellationToken => tokenDataSource.OpenConnectionAsync(cancellationToken).AsTask()); var subscriptions = transport.Subscriptions; diff --git a/src/ServiceControl.Transports.PostgreSql/QueueLengthProvider.cs b/src/ServiceControl.Transports.PostgreSql/QueueLengthProvider.cs index a4f2f2cc74..38a2351513 100644 --- a/src/ServiceControl.Transports.PostgreSql/QueueLengthProvider.cs +++ b/src/ServiceControl.Transports.PostgreSql/QueueLengthProvider.cs @@ -11,10 +11,11 @@ class QueueLengthProvider : AbstractQueueLengthProvider { - public QueueLengthProvider(TransportSettings settings, Action store, ILogger logger) : base(settings, store) + public QueueLengthProvider(TransportSettings settings, Action store, ILogger logger, PostgreSqlTokenDataSources tokenDataSources) : base(settings, store) { connectionString = ConnectionString .RemoveCustomConnectionStringParts(out var customSchema, out _); + tokenDataSource = tokenDataSources.GetOrCreate(connectionString, PostgreSqlTransportCustomization.TransportConnectionStringSetting, out _); defaultSchema = customSchema ?? "public"; this.logger = logger; @@ -91,7 +92,7 @@ async Task QueryTableSizes(CancellationToken cancellationToken) .Select(grp => grp.Select(g => g.i).ToArray()) .ToList(); - await using var connection = new NpgsqlConnection(connectionString); + await using var connection = tokenDataSource?.CreateConnection() ?? new NpgsqlConnection(connectionString); await connection.OpenAsync(cancellationToken); foreach (var chunk in chunks) @@ -129,6 +130,7 @@ async Task UpdateChunk(NpgsqlConnection connection, KeyValuePair tableSizes = new ConcurrentDictionary(); readonly string connectionString; + readonly NpgsqlDataSource tokenDataSource; readonly string defaultSchema; readonly ILogger logger; diff --git a/src/ServiceControl.Transports.PostgreSql/ServiceControl.Transports.PostgreSql.csproj b/src/ServiceControl.Transports.PostgreSql/ServiceControl.Transports.PostgreSql.csproj index b29d5d7624..08f5b8107d 100644 --- a/src/ServiceControl.Transports.PostgreSql/ServiceControl.Transports.PostgreSql.csproj +++ b/src/ServiceControl.Transports.PostgreSql/ServiceControl.Transports.PostgreSql.csproj @@ -11,13 +11,25 @@ + + + + + + + + + + + + diff --git a/src/ServiceControl.Transports.PostgreSql/transport.manifest b/src/ServiceControl.Transports.PostgreSql/transport.manifest index 4e8462bb81..0fe301eb72 100644 --- a/src/ServiceControl.Transports.PostgreSql/transport.manifest +++ b/src/ServiceControl.Transports.PostgreSql/transport.manifest @@ -7,7 +7,7 @@ "TypeName": "ServiceControl.Transports.PostgreSql.PostgreSqlTransportCustomization, ServiceControl.Transports.PostgreSql", "SampleConnectionString": "Server=;Database=nservicebus;Port=5432;User Id=;Password=;Queue Schema=myschema;Subscriptions Table=schema.tablename", "AvailableInSCMU": true, - "Help": "Specify optional 'Queue Schema' to override the default schema. Specify optional 'Subscriptions Table' to override the default subscriptions table location." + "Help": "Specify optional 'Queue Schema' to override the default schema. Specify optional 'Subscriptions Table' to override the default subscriptions table location. To sign in with a Microsoft Entra ID or AWS IAM token instead of a password, add 'Token Authentication=AzureEntraId' or 'Token Authentication=AwsRdsIam' and remove 'Password'." } ] } \ No newline at end of file