From 5912c43621b45322e82ff8bc1790f5af2a1441ed Mon Sep 17 00:00:00 2001 From: John Simons Date: Thu, 1 Oct 2026 16:08:15 +1000 Subject: [PATCH 1/2] Add transport, storage, and error instance configuration telemetry to the usage report Expands the environment data collected for the usage report to cover transport authentication and options (ASBS, SQS, RabbitMQ, SQL Server, PostgreSQL), storage authentication and tuning (RavenDB, SQL Server, PostgreSQL, EF Core), and error instance settings (ingestion tuning, heartbeats, recoverability, security posture, ServicePulse integration, logging, and OTLP metrics). Values are reported as fixed labels rather than raw configuration values, so customer-specific details (queue names, endpoints, secrets, hostnames, claim names) are never exposed. --- .../When_reporting_the_environment.cs | 135 +++++++++++ .../OpenIdConnectSettings.cs | 10 +- .../PostgreSqlPersistence.cs | 2 + ...geAuthenticationEnvironmentDataProvider.cs | 25 ++ .../SqlServerPersistence.cs | 2 + ...geAuthenticationEnvironmentDataProvider.cs | 25 ++ .../EFPersistenceConfigurationBase.cs | 10 +- .../EFEnvironmentDataProvider.cs | 24 +- .../RavenEnvironmentDataProvider.cs | 28 ++- ...orageAuthenticationEnvironmentDataTests.cs | 24 ++ .../EnvironmentDataConfigurationTests.cs | 98 ++++++++ ...orageAuthenticationEnvironmentDataTests.cs | 25 ++ .../EnvironmentDataConfigurationTests.cs | 113 +++++++++ .../EnvironmentDataTests.cs | 70 ++++++ .../ASBSTransportCustomization.cs | 39 ++- .../EnvironmentDataTests.cs | 49 ++++ .../PostgreSqlTransportCustomization.cs | 28 +++ ...nventionalRoutingTransportCustomization.cs | 10 + ...itMQDirectRoutingTransportCustomization.cs | 10 + .../RabbitMQTransportExtensions.cs | 43 +++- .../EnvironmentDataTests.cs | 51 ++++ .../EnvironmentDataTests.cs | 53 ++++ .../SQSTransportConnectionString.cs | 4 +- .../SQSTransportCustomization.cs | 33 +++ .../EnvironmentDataTests.cs | 50 ++++ .../SqlServerTransportCustomization.cs | 28 +++ ...ovals.ServiceControlTransport.approved.txt | 11 + .../TransportCustomization.cs | 15 ++ .../TransportEnvironmentDatum.cs | 6 + ...tanceTuningEnvironmentDataProviderTests.cs | 84 +++++++ ...rorInstanceEnvironmentDataProviderTests.cs | 226 ++++++++++++++++++ ...nceStoredChoicesEnvironmentDataProvider.cs | 57 +++++ ...orInstanceTuningEnvironmentDataProvider.cs | 36 +++ .../HostApplicationBuilderExtensions.cs | 3 + .../Infrastructure/Settings/Settings.cs | 18 +- ...rolErrorInstanceEnvironmentDataProvider.cs | 102 +++++++- .../TransportEnvironmentDataProvider.cs | 17 ++ 37 files changed, 1535 insertions(+), 29 deletions(-) create mode 100644 src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlStorageAuthenticationEnvironmentDataProvider.cs create mode 100644 src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerStorageAuthenticationEnvironmentDataProvider.cs create mode 100644 src/ServiceControl.Persistence.Tests.PostgreSql/StorageAuthenticationEnvironmentDataTests.cs create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/EnvironmentDataConfigurationTests.cs create mode 100644 src/ServiceControl.Persistence.Tests.SqlServer/StorageAuthenticationEnvironmentDataTests.cs create mode 100644 src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataConfigurationTests.cs create mode 100644 src/ServiceControl.Transports.ASBS.Tests/EnvironmentDataTests.cs create mode 100644 src/ServiceControl.Transports.PostgreSql.Tests/EnvironmentDataTests.cs create mode 100644 src/ServiceControl.Transports.RabbitMQClassicConventionalRouting.Tests/EnvironmentDataTests.cs create mode 100644 src/ServiceControl.Transports.SQS.Tests/EnvironmentDataTests.cs create mode 100644 src/ServiceControl.Transports.SqlServer.Tests/EnvironmentDataTests.cs create mode 100644 src/ServiceControl.Transports/TransportEnvironmentDatum.cs create mode 100644 src/ServiceControl.UnitTests/Licensing/ErrorInstanceTuningEnvironmentDataProviderTests.cs create mode 100644 src/ServiceControl.UnitTests/Licensing/ServiceControlErrorInstanceEnvironmentDataProviderTests.cs create mode 100644 src/ServiceControl/ErrorInstanceStoredChoicesEnvironmentDataProvider.cs create mode 100644 src/ServiceControl/ErrorInstanceTuningEnvironmentDataProvider.cs create mode 100644 src/ServiceControl/TransportEnvironmentDataProvider.cs diff --git a/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs b/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs index f3b4403d00..d1acc58fc3 100644 --- a/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs +++ b/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs @@ -16,6 +16,11 @@ namespace ServiceControl.AcceptanceTests.Licensing using Particular.LicensingComponent.Contracts; using Particular.LicensingComponent.MonitoringThroughput; using Particular.LicensingComponent.Shared; + using Recoverability.MessageRedirects; + using ServiceBus.Management.Infrastructure.Settings; + using ServiceControl.Monitoring; + using ServiceControl.Persistence; + using Conventions = NServiceBus.AcceptanceTesting.Customization.Conventions; class When_reporting_the_environment : AcceptanceTest { @@ -86,18 +91,137 @@ await Define() "Storage.FullTextSearch", "Storage.BodyStorage.Type", "Storage.BodyStorage.Auth", + "Storage.Auth", + "Storage.QueryTimeoutSeconds", + "Storage.FreeSpaceThresholdPercent", + "Transport.Type", + "Transport.Auth", + "Transport.CertificateValidation", "Security.Authentication", "Security.RoleBasedAuthorization", + "Security.TokenValidation", + "Security.ClaimMapping", + "Security.ServicePulseOfflineAccess", "Security.Https", + "Security.HttpsHardening", + "Security.Cors", + "Security.ForwardedHeaders", "Features.IntegratedServicePulse", "Features.MessageEditing", "Features.ExternalIntegrationsPublishing", "Features.ForwardErrorMessages", + "Features.ErrorIngestion", + "Features.ConfigurationValidation", "Features.EmailNotifications", + "ServicePulse.MonitoringUrl", + "ServicePulse.DefaultRoute", + "ServicePulse.ShowPendingRetry", + "Host.VirtualDirectory", + "Host.ShutdownTimeoutSeconds", + "Logging.Providers", + "Logging.Level", + "Telemetry.OtlpMetrics", + "Limits.ExternalIntegrationsBatchSize", + "Ingestion.Error.MaxConcurrency", + "Ingestion.Error.BatchSize", + "Ingestion.Error.MaxParallelWriters", + "Ingestion.Error.BatchTimeoutMs", + "Ingestion.Error.RestartAfterFailureSeconds", + "Heartbeats.TrackInstancesDefault", + "Heartbeats.TrackInstancesOverrides", + "Heartbeats.KnownInstances", + "Heartbeats.MonitoredInstances", + "Heartbeats.GracePeriodSeconds", + "Recoverability.Redirects", + "Recoverability.RetryHistoryDepth", + "Licensing.ReportMasks", "Retention.ErrorHours", "Retention.EventsHours" ]; + [Test] + public async Task Should_count_the_choices_made_in_servicepulse_without_revealing_them() + { + JsonDocument report = null; + + await Define() + .WithEndpoint() + .WithEndpoint() + .Do("Wait for the first heartbeat of the endpoint that scales out", async _ => + { + var endpoints = await this.TryGetMany("/api/endpoints", endpoint => endpoint.Name == ScalingOutEndpoint && endpoint.Monitored); + + return endpoints.HasResult; + }) + .Do("Stop tracking instances by default", async _ => + { + await this.Patch("/api/endpointssettings", new { track_instances = false }); + + var settings = await this.TryGetMany("/api/endpointssettings", + setting => setting.Name == string.Empty && !setting.TrackInstances); + + return settings.HasResult; + }) + .Do("Keep tracking the endpoint that scales out", async _ => + { + await this.Patch($"/api/endpointssettings/{ScalingOutEndpoint}", new { track_instances = true }); + + var settings = await this.TryGetMany("/api/endpointssettings", + setting => setting.Name == ScalingOutEndpoint && setting.TrackInstances); + + return settings.HasResult; + }) + .Do("Redirect a retired queue", async _ => + { + await this.Post("/api/redirects", new RedirectRequest { fromphysicaladdress = RetiredQueue, tophysicaladdress = ReplacementQueue }); + + var redirects = await this.TryGetMany("/api/redirects"); + + return redirects.HasResult; + }) + .Do("Mask the customer's name in the report", async _ => + { + await this.Post("/api/licensing/settings/masks/update", new[] { MaskedWord }); + + return true; + }) + .Do("Wait for the throughput data to be recorded", async _ => + { + var available = await this.TryGet( + "/api/licensing/report/available", state => state.ReportCanBeGenerated); + + return available.HasResult; + }) + .Do("Download the report", async _ => + { + var archive = await this.DownloadData("/api/licensing/report/file?spVersion=1.2.3"); + + report = ReadReport(archive); + + return true; + }) + .Done(_ => true) + .Run(); + + var data = report.RootElement + .GetProperty("ReportData") + .GetProperty("EnvironmentInformation") + .GetProperty("EnvironmentData") + .EnumerateObject() + .ToDictionary(entry => entry.Name, entry => entry.Value.GetString()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Heartbeats.TrackInstancesDefault"], Is.EqualTo("Disabled")); + Assert.That(data["Heartbeats.TrackInstancesOverrides"], Is.EqualTo("1")); + Assert.That(int.Parse(data["Heartbeats.MonitoredInstances"]), Is.GreaterThanOrEqualTo(1)); + Assert.That(int.Parse(data["Heartbeats.KnownInstances"]), Is.GreaterThanOrEqualTo(int.Parse(data["Heartbeats.MonitoredInstances"]))); + Assert.That(data["Recoverability.Redirects"], Is.EqualTo("1")); + Assert.That(data["Licensing.ReportMasks"], Is.EqualTo("1")); + Assert.That(string.Join("|", data.Values), Does.Not.Contain(RetiredQueue).And.Not.Contain(ReplacementQueue).And.Not.Contain(MaskedWord).And.Not.Contain(ScalingOutEndpoint)); + } + } + static JsonDocument ReadReport(byte[] archive) { using var zip = new ZipArchive(new MemoryStream(archive), ZipArchiveMode.Read); @@ -107,12 +231,23 @@ static JsonDocument ReadReport(byte[] archive) } const string SalesEndpoint = "Particular.Sales"; + const string RetiredQueue = "Contoso.Billing.Legacy@machine1"; + const string ReplacementQueue = "Contoso.Billing@machine2"; + const string MaskedWord = "Contoso"; class Context : ScenarioContext, ISequenceContext { public int Step { get; set; } } + static string ScalingOutEndpoint => Conventions.EndpointNamingConvention(typeof(ScalingOut)); + + class ScalingOut : EndpointConfigurationBuilder + { + public ScalingOut() => + EndpointSetup(c => c.SendHeartbeatTo(Settings.DEFAULT_INSTANCE_NAME)); + } + class MonitoringInstance : EndpointConfigurationBuilder { public MonitoringInstance() => diff --git a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs index 81f6080836..2390311c01 100644 --- a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs +++ b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs @@ -11,6 +11,10 @@ namespace ServiceControl.Infrastructure; /// public class OpenIdConnectSettings { + public const string DefaultRolesClaim = "roles"; + public const string DefaultSubjectIdClaim = "sub"; + public const string DefaultSubjectNameClaim = "preferred_username"; + readonly ILogger logger = LoggerUtil.CreateStaticLogger(); /// @@ -33,15 +37,15 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC ValidateIssuerSigningKey = SettingsReader.Read(rootNamespace, "Authentication.ValidateIssuerSigningKey", true); RequireHttpsMetadata = SettingsReader.Read(rootNamespace, "Authentication.RequireHttpsMetadata", true); - RolesClaim = SettingsReader.Read(rootNamespace, "Authentication.RolesClaim", "roles"); + RolesClaim = SettingsReader.Read(rootNamespace, "Authentication.RolesClaim", DefaultRolesClaim); RoleBasedAuthorizationEnabled = SettingsReader.Read(rootNamespace, "Authentication.RoleBasedAuthorizationEnabled", false); // Claims that identify the principal in the authorization audit log. The handler treats both // as required — a missing or empty value is a sign that the IdP isn't emitting the expected // claim and the operator needs to fix the configuration, so the handler will throw rather // than substitute a placeholder. - SubjectIdClaim = SettingsReader.Read(rootNamespace, "Authentication.SubjectIdClaim", "sub"); - SubjectNameClaim = SettingsReader.Read(rootNamespace, "Authentication.SubjectNameClaim", "preferred_username"); + SubjectIdClaim = SettingsReader.Read(rootNamespace, "Authentication.SubjectIdClaim", DefaultSubjectIdClaim); + SubjectNameClaim = SettingsReader.Read(rootNamespace, "Authentication.SubjectNameClaim", DefaultSubjectNameClaim); // ServicePulse settings are only relevant for the primary ServiceControl instance // which serves the OIDC configuration endpoint that ServicePulse uses for login diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs index 3cbe6c1549..4702ccbd49 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs @@ -5,6 +5,7 @@ namespace ServiceControl.Persistence.EFCore.PostgreSql; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.DependencyInjection; +using Particular.LicensingComponent.Contracts; using ServiceControl.Persistence.EFCore.Abstractions; using ServiceControl.Persistence.EFCore.DbContexts; using ServiceControl.Persistence.EFCore.Infrastructure; @@ -17,6 +18,7 @@ public void AddPersistence(IServiceCollection services) ConfigureDbContext(services); RegisterDataStores(services, settings); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlStorageAuthenticationEnvironmentDataProvider.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlStorageAuthenticationEnvironmentDataProvider.cs new file mode 100644 index 0000000000..1c8b849274 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlStorageAuthenticationEnvironmentDataProvider.cs @@ -0,0 +1,25 @@ +namespace ServiceControl.Persistence.EFCore.PostgreSql; + +using Npgsql; +using Particular.LicensingComponent.Contracts; +using static Particular.LicensingComponent.Contracts.EnvironmentDatum; + +class PostgreSqlStorageAuthenticationEnvironmentDataProvider(PostgreSqlPersisterSettings settings) : IEnvironmentDataProvider +{ + public IEnumerable GetData() => + [ + Value("Storage.Auth", Authentication) + ]; + + string Authentication() + { + var builder = new NpgsqlConnectionStringBuilder(settings.ConnectionString); + + if (!string.IsNullOrEmpty(builder.SslCertificate)) + { + return "ClientCertificate"; + } + + return !string.IsNullOrEmpty(builder.Password) || !string.IsNullOrEmpty(builder.Passfile) ? "Password" : "None"; + } +} diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerPersistence.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerPersistence.cs index dead62d3cc..41bf2fcf45 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerPersistence.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerPersistence.cs @@ -5,6 +5,7 @@ namespace ServiceControl.Persistence.EFCore.SqlServer; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.DependencyInjection; +using Particular.LicensingComponent.Contracts; using ServiceControl.Persistence.EFCore.Abstractions; using ServiceControl.Persistence.EFCore.DbContexts; using ServiceControl.Persistence.EFCore.Infrastructure; @@ -17,6 +18,7 @@ public void AddPersistence(IServiceCollection services) ConfigureDbContext(services); RegisterDataStores(services, settings); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerStorageAuthenticationEnvironmentDataProvider.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerStorageAuthenticationEnvironmentDataProvider.cs new file mode 100644 index 0000000000..4ee5a8c318 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerStorageAuthenticationEnvironmentDataProvider.cs @@ -0,0 +1,25 @@ +namespace ServiceControl.Persistence.EFCore.SqlServer; + +using Microsoft.Data.SqlClient; +using Particular.LicensingComponent.Contracts; +using static Particular.LicensingComponent.Contracts.EnvironmentDatum; + +class SqlServerStorageAuthenticationEnvironmentDataProvider(SqlServerPersisterSettings settings) : IEnvironmentDataProvider +{ + public IEnumerable GetData() => + [ + Value("Storage.Auth", Authentication) + ]; + + string Authentication() + { + var builder = new SqlConnectionStringBuilder(settings.ConnectionString); + + if (builder.Authentication is SqlAuthenticationMethod.NotSpecified) + { + return builder.IntegratedSecurity ? "Integrated" : "SqlPassword"; + } + + return builder.Authentication is SqlAuthenticationMethod.SqlPassword ? "SqlPassword" : "EntraId"; + } +} diff --git a/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs b/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs index 727aef4b98..c708103e7c 100644 --- a/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs +++ b/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs @@ -7,11 +7,11 @@ namespace ServiceControl.Persistence.EFCore.Abstractions; public abstract class EFPersistenceConfigurationBase : PersistenceConfiguration, IPersistenceConfiguration { const string ConnectionStringKey = "Database/ConnectionString"; - const string SchemaKey = "Database/Schema"; - const string CommandTimeoutKey = "Database/CommandTimeout"; + internal const string SchemaKey = "Database/Schema"; + internal const string CommandTimeoutKey = "Database/CommandTimeout"; const string BodyStorageTypeKey = "MessageBody/StorageType"; const string FileSystemStoragePathKey = "MessageBody/FileSystem/StoragePath"; - const string FileSystemDataSpaceRemainingThresholdKey = "MessageBody/FileSystem/DataSpaceRemainingThreshold"; + internal const string FileSystemDataSpaceRemainingThresholdKey = "MessageBody/FileSystem/DataSpaceRemainingThreshold"; const string AzureConnectionStringKey = "MessageBody/Azure/ConnectionString"; const string AzureServiceUriKey = "MessageBody/Azure/ServiceUri"; const string AzureManagedIdentityClientIdKey = "MessageBody/Azure/ManagedIdentityClientId"; @@ -23,11 +23,11 @@ public abstract class EFPersistenceConfigurationBase : PersistenceConfiguration, const string S3ServiceUrlKey = "MessageBody/S3/ServiceUrl"; const string S3AccessKeyIdKey = "MessageBody/S3/AccessKeyId"; const string S3SecretAccessKeyKey = "MessageBody/S3/SecretAccessKey"; - const string MinBodySizeForCompressionKey = "MessageBody/MinCompressionSize"; + internal const string MinBodySizeForCompressionKey = "MessageBody/MinCompressionSize"; const string MaxBodySizeToStoreKey = "MaxBodySizeToStore"; const string ErrorRetentionPeriodKey = "ErrorRetentionPeriod"; const string EventsRetentionPeriodKey = "EventsRetentionPeriod"; - const string SubscriptionCacheDurationKey = "SubscriptionCacheDuration"; + internal const string SubscriptionCacheDurationKey = "SubscriptionCacheDuration"; const string ExternalIntegrationsDispatchingBatchSizeKey = "ExternalIntegrationsDispatchingBatchSize"; public bool SupportsMaintenanceMode => false; diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs b/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs index d95f90646c..dcf5e2a0da 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs @@ -4,6 +4,8 @@ namespace ServiceControl.Persistence.EFCore.Implementation; using Abstractions; using Infrastructure; using Particular.LicensingComponent.Contracts; +using ServiceControl.Configuration; +using ServiceControl.Infrastructure; using static Particular.LicensingComponent.Contracts.EnvironmentDatum; class EFEnvironmentDataProvider(EFPersisterSettings settings, IDatabaseHostingProbe hostingProbe) : IEnvironmentDataProvider @@ -24,10 +26,28 @@ public IEnumerable GetData() Value("Storage.FullTextSearch", () => settings.EnableFullTextSearchOnBodies ? "Enabled" : "Disabled"), Value("Storage.BodyStorage.Type", () => BodyStorageType(settings.BodyStorage)), Value("Storage.BodyStorage.Auth", () => BodyStorageAuth(settings.BodyStorage)), - Value("Limits.MaxBodySizeToStore", () => settings.BodyStorage.MaxBodySizeToStore.ToString(CultureInfo.InvariantCulture)) + Value("Limits.MaxBodySizeToStore", () => settings.BodyStorage.MaxBodySizeToStore.ToString(CultureInfo.InvariantCulture)), + Value("Storage.Schema", () => settings.Schema is null ? "Default" : "Custom"), + Value("Storage.CommandTimeoutSeconds", () => WhenConfigured(EFPersistenceConfigurationBase.CommandTimeoutKey, () => Number(settings.CommandTimeout))), + Value("Storage.QueryTimeoutSeconds", () => WhenConfigured(QueryTimeLimit.SettingName, () => Seconds(settings.QueryTimeout))), + Value("Storage.SubscriptionCacheSeconds", () => WhenConfigured(EFPersistenceConfigurationBase.SubscriptionCacheDurationKey, () => Seconds(settings.SubscriptionCacheDuration))), + Value("Storage.BodyStorage.MinCompressionBytes", () => WhenConfigured(EFPersistenceConfigurationBase.MinBodySizeForCompressionKey, () => Number(settings.BodyStorage.MinCompressionSize))), + Value("Storage.FreeSpaceThresholdPercent", FreeSpaceThreshold) ]; } + string FreeSpaceThreshold() => settings.BodyStorage is FileSystemBodyStorageSettings fileSystem + ? WhenConfigured(EFPersistenceConfigurationBase.FileSystemDataSpaceRemainingThresholdKey, () => Number(fileSystem.DataSpaceRemainingThreshold)) + : "NotApplicable"; + + static string WhenConfigured(string key, Func readValue) => + SettingsReader.TryRead(SettingsNamespace, key, out _) ? readValue() : "Default"; + + static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); + + static string Seconds(TimeSpan value) => + Math.Round(value.TotalSeconds, MidpointRounding.AwayFromZero).ToString("F0", CultureInfo.InvariantCulture); + static string BodyStorageType(BodyStorageSettings bodyStorage) => bodyStorage switch { FileSystemBodyStorageSettings => nameof(Abstractions.BodyStorageType.FileSystem), @@ -47,4 +67,6 @@ public IEnumerable GetData() S3BodyStorageSettings s3 => s3.Credentials is null ? "IamRole" : "StaticCredentials", _ => "NotApplicable" }; + + static readonly SettingsRootNamespace SettingsNamespace = new("ServiceControl"); } diff --git a/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs b/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs index d1f596f1d2..5dc4f6321b 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs @@ -2,10 +2,13 @@ namespace ServiceControl.Persistence.RavenDB; using System; using System.Collections.Generic; +using System.Globalization; using System.Threading; using System.Threading.Tasks; using Particular.LicensingComponent.Contracts; using Raven.Client.ServerWide.Operations; +using ServiceControl.Configuration; +using ServiceControl.Infrastructure; using static Particular.LicensingComponent.Contracts.EnvironmentDatum; class RavenEnvironmentDataProvider(RavenPersisterSettings settings, IRavenDocumentStoreProvider documentStoreProvider) : IEnvironmentDataProvider @@ -19,7 +22,13 @@ public IEnumerable GetData() => Value("Storage.HostingSource", () => Hosting().Source), Value("Storage.FullTextSearch", () => settings.EnableFullTextSearchOnBodies ? "Enabled" : "Disabled"), Value("Storage.BodyStorage.Type", () => "RavenAttachments"), - Value("Storage.BodyStorage.Auth", () => "NotApplicable") + Value("Storage.BodyStorage.Auth", () => "NotApplicable"), + Value("Storage.Auth", Authentication), + Value("Storage.LogLevel", () => settings.LogsMode), + Value("Storage.QueryTimeoutSeconds", () => WhenConfigured(QueryTimeLimit.SettingName, () => Number((int)Math.Round(settings.QueryTimeout.TotalSeconds, MidpointRounding.AwayFromZero)))), + Value("Storage.FreeSpaceThresholdPercent", () => WhenConfigured(RavenPersistenceConfiguration.DataSpaceRemainingThresholdKey, () => Number(settings.DataSpaceRemainingThreshold))), + Value("Storage.MinimumFreeSpaceForIngestionPercent", () => WhenConfigured(RavenBootstrapper.MinimumStorageLeftRequiredForIngestionKey, () => Number(settings.MinimumStorageLeftRequiredForIngestion))), + Value("Storage.ExpirationIntervalSeconds", () => WhenConfigured(RavenBootstrapper.ExpirationProcessTimerInSecondsKey, () => Number(settings.ExpirationProcessTimerInSeconds))) ]; (string Hosting, string Source) Hosting() @@ -42,4 +51,21 @@ async ValueTask ServerVersion(CancellationToken cancellationToken) return buildNumber.ProductVersion ?? DatabaseHostClassifier.Unknown; } + + string Authentication() + { + if (settings.UseEmbeddedServer) + { + return "NotApplicable"; + } + + return string.IsNullOrWhiteSpace(settings.ClientCertificatePath) && string.IsNullOrWhiteSpace(settings.ClientCertificateBase64) ? "None" : "ClientCertificate"; + } + + static string WhenConfigured(string key, Func readValue) => + SettingsReader.TryRead(SettingsNamespace, key, out _) ? readValue() : "Default"; + + static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); + + static readonly SettingsRootNamespace SettingsNamespace = new("ServiceControl"); } diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/StorageAuthenticationEnvironmentDataTests.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/StorageAuthenticationEnvironmentDataTests.cs new file mode 100644 index 0000000000..c16d00545e --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.PostgreSql/StorageAuthenticationEnvironmentDataTests.cs @@ -0,0 +1,24 @@ +namespace ServiceControl.Persistence.Tests.PostgreSql; + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using ServiceControl.Persistence.EFCore.Abstractions; +using ServiceControl.Persistence.EFCore.PostgreSql; + +[TestFixture] +class StorageAuthenticationEnvironmentDataTests +{ + [TestCase("Host=localhost;Database=sc;Username=sc;Password=contoso-secret", "Password")] + [TestCase("Host=localhost;Database=sc;Username=sc;Passfile=/home/sc/.pgpass", "Password")] + [TestCase("Host=localhost;Database=sc;Username=sc;SSL Certificate=/certs/client.crt", "ClientCertificate")] + [TestCase("Host=localhost;Database=sc;Username=sc", "None")] + public async Task Should_report_the_authentication_mode(string connectionString, string expected) + { + var settings = new PostgreSqlPersisterSettings { ConnectionString = connectionString, BodyStorage = new FileSystemBodyStorageSettings { StoragePath = "/var/bodies" } }; + var datum = new PostgreSqlStorageAuthenticationEnvironmentDataProvider(settings).GetData().Single(); + + Assert.That(await datum.ReadValue(CancellationToken.None), Is.EqualTo(expected)); + } +} diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/EnvironmentDataConfigurationTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/EnvironmentDataConfigurationTests.cs new file mode 100644 index 0000000000..06548accaf --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/EnvironmentDataConfigurationTests.cs @@ -0,0 +1,98 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using ServiceControl.Persistence.RavenDB; + +[TestFixture] +[NonParallelizable] +class RavenEnvironmentDataProviderConfigurationTests +{ + [TearDown] + public void TearDown() + { + foreach (var variable in Variables) + { + Environment.SetEnvironmentVariable(variable, null); + } + } + + [Test] + public async Task Should_report_an_embedded_server_with_its_defaults() + { + var data = await GetData(new RavenPersisterSettings()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Storage.Auth"], Is.EqualTo("NotApplicable")); + Assert.That(data["Storage.LogLevel"], Is.EqualTo("Operations")); + Assert.That(data["Storage.QueryTimeoutSeconds"], Is.EqualTo("Default")); + Assert.That(data["Storage.FreeSpaceThresholdPercent"], Is.EqualTo("Default")); + Assert.That(data["Storage.MinimumFreeSpaceForIngestionPercent"], Is.EqualTo("Default")); + Assert.That(data["Storage.ExpirationIntervalSeconds"], Is.EqualTo("Default")); + } + } + + [TestCase(null, "None")] + [TestCase("/certs/contoso.pfx", "ClientCertificate")] + public async Task Should_report_how_an_external_server_authenticates_without_naming_it(string clientCertificatePath, string expected) + { + var data = await GetData(new RavenPersisterSettings { ConnectionString = "https://raven.contoso.local:8080", ClientCertificatePath = clientCertificatePath }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Storage.Auth"], Is.EqualTo(expected)); + Assert.That(string.Join("|", data.Values), Does.Not.Contain("contoso")); + } + } + + [Test] + public async Task Should_report_configured_tuning_values() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_DATASPACEREMAININGTHRESHOLD", "30"); + Environment.SetEnvironmentVariable("SERVICECONTROL_EXPIRATIONPROCESSTIMERINSECONDS", "300"); + + var data = await GetData(new RavenPersisterSettings { DataSpaceRemainingThreshold = 30, ExpirationProcessTimerInSeconds = 300 }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Storage.FreeSpaceThresholdPercent"], Is.EqualTo("30")); + Assert.That(data["Storage.ExpirationIntervalSeconds"], Is.EqualTo("300")); + Assert.That(data["Storage.MinimumFreeSpaceForIngestionPercent"], Is.EqualTo("Default")); + } + } + + static async Task> GetData(RavenPersisterSettings settings) + { + var data = new Dictionary(); + + foreach (var datum in new RavenEnvironmentDataProvider(settings, documentStoreProvider: null).GetData()) + { + if (ConfigurationKeys.Contains(datum.Key)) + { + data[datum.Key] = await datum.ReadValue(CancellationToken.None); + } + } + + return data; + } + + static readonly HashSet ConfigurationKeys = + [ + "Storage.Auth", + "Storage.LogLevel", + "Storage.QueryTimeoutSeconds", + "Storage.FreeSpaceThresholdPercent", + "Storage.MinimumFreeSpaceForIngestionPercent", + "Storage.ExpirationIntervalSeconds" + ]; + + static readonly string[] Variables = + [ + "SERVICECONTROL_DATASPACEREMAININGTHRESHOLD", + "SERVICECONTROL_EXPIRATIONPROCESSTIMERINSECONDS" + ]; +} diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/StorageAuthenticationEnvironmentDataTests.cs b/src/ServiceControl.Persistence.Tests.SqlServer/StorageAuthenticationEnvironmentDataTests.cs new file mode 100644 index 0000000000..22a269438d --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.SqlServer/StorageAuthenticationEnvironmentDataTests.cs @@ -0,0 +1,25 @@ +namespace ServiceControl.Persistence.Tests.SqlServer; + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using ServiceControl.Persistence.EFCore.Abstractions; +using ServiceControl.Persistence.EFCore.SqlServer; + +[TestFixture] +class StorageAuthenticationEnvironmentDataTests +{ + [TestCase("Server=.;Database=sc;Integrated Security=true", "Integrated")] + [TestCase("Server=.;Database=sc;User Id=sc;Password=contoso-secret", "SqlPassword")] + [TestCase("Server=tcp:contoso.database.windows.net;Database=sc;Authentication=Active Directory Managed Identity", "EntraId")] + [TestCase("Server=tcp:contoso.database.windows.net;Database=sc;Authentication=Active Directory Default", "EntraId")] + [TestCase("Server=tcp:contoso.database.windows.net;Database=sc;Authentication=Sql Password;User Id=sc;Password=contoso-secret", "SqlPassword")] + public async Task Should_report_the_authentication_mode(string connectionString, string expected) + { + var settings = new SqlServerPersisterSettings { ConnectionString = connectionString, BodyStorage = new FileSystemBodyStorageSettings { StoragePath = "/var/bodies" } }; + var datum = new SqlServerStorageAuthenticationEnvironmentDataProvider(settings).GetData().Single(); + + Assert.That(await datum.ReadValue(CancellationToken.None), Is.EqualTo(expected)); + } +} diff --git a/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataConfigurationTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataConfigurationTests.cs new file mode 100644 index 0000000000..daab19d03a --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataConfigurationTests.cs @@ -0,0 +1,113 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using ServiceControl.Persistence.EFCore.Abstractions; +using ServiceControl.Persistence.EFCore.Implementation; +using ServiceControl.Persistence.EFCore.Infrastructure; + +[TestFixture] +[NonParallelizable] +class EFEnvironmentDataProviderConfigurationTests +{ + [TearDown] + public void TearDown() + { + foreach (var variable in Variables) + { + Environment.SetEnvironmentVariable(variable, null); + } + } + + [Test] + public async Task Should_report_defaults_when_nothing_is_configured() + { + var data = await GetData(new TestPersisterSettings { ConnectionString = "Host=localhost", BodyStorage = new FileSystemBodyStorageSettings { StoragePath = "/var/bodies" } }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Storage.Schema"], Is.EqualTo("Default")); + Assert.That(data["Storage.CommandTimeoutSeconds"], Is.EqualTo("Default")); + Assert.That(data["Storage.QueryTimeoutSeconds"], Is.EqualTo("Default")); + Assert.That(data["Storage.SubscriptionCacheSeconds"], Is.EqualTo("Default")); + Assert.That(data["Storage.BodyStorage.MinCompressionBytes"], Is.EqualTo("Default")); + Assert.That(data["Storage.FreeSpaceThresholdPercent"], Is.EqualTo("Default")); + } + } + + [Test] + public async Task Should_report_configured_values_in_the_unit_their_keys_name() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_DATABASE_COMMANDTIMEOUT", "60"); + Environment.SetEnvironmentVariable("SERVICECONTROL_QUERYTIMEOUTINSECONDS", "120"); + Environment.SetEnvironmentVariable("SERVICECONTROL_MESSAGEBODY_FILESYSTEM_DATASPACEREMAININGTHRESHOLD", "15"); + + var data = await GetData(new TestPersisterSettings + { + ConnectionString = "Host=localhost", + BodyStorage = new FileSystemBodyStorageSettings { StoragePath = "/var/bodies", DataSpaceRemainingThreshold = 15 }, + CommandTimeout = 60, + QueryTimeout = TimeSpan.FromSeconds(120) + }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Storage.CommandTimeoutSeconds"], Is.EqualTo("60")); + Assert.That(data["Storage.QueryTimeoutSeconds"], Is.EqualTo("120")); + Assert.That(data["Storage.FreeSpaceThresholdPercent"], Is.EqualTo("15")); + Assert.That(data["Storage.SubscriptionCacheSeconds"], Is.EqualTo("Default")); + } + } + + [Test] + public async Task Should_report_a_custom_schema_without_naming_it() + { + var data = await GetData(new TestPersisterSettings { ConnectionString = "Host=localhost", BodyStorage = new FileSystemBodyStorageSettings { StoragePath = "/var/bodies" }, Schema = "contoso" }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Storage.Schema"], Is.EqualTo("Custom")); + Assert.That(string.Join("|", data.Values), Does.Not.Contain("contoso")); + } + } + + [Test] + public async Task Should_report_the_free_space_threshold_as_not_applicable_for_cloud_body_storage() + { + var data = await GetData(new TestPersisterSettings { ConnectionString = "Host=localhost", BodyStorage = new S3BodyStorageSettings { BucketName = "bodies" } }); + + Assert.That(data["Storage.FreeSpaceThresholdPercent"], Is.EqualTo("NotApplicable")); + } + + static async Task> GetData(EFPersisterSettings settings) + { + var data = new Dictionary(); + + foreach (var datum in new EFEnvironmentDataProvider(settings, new StubHostingProbe()).GetData()) + { + data[datum.Key] = await datum.ReadValue(CancellationToken.None); + } + + return data; + } + + static readonly string[] Variables = + [ + "SERVICECONTROL_DATABASE_COMMANDTIMEOUT", + "SERVICECONTROL_QUERYTIMEOUTINSECONDS", + "SERVICECONTROL_MESSAGEBODY_FILESYSTEM_DATASPACEREMAININGTHRESHOLD" + ]; + + sealed class TestPersisterSettings : EFPersisterSettings; + + sealed class StubHostingProbe : IDatabaseHostingProbe + { + public string StorageName => "PostgreSQL"; + + public Task Probe(CancellationToken cancellationToken = default) => + Task.FromResult(DatabaseHosting.Unclassified); + } +} diff --git a/src/ServiceControl.Transports.ASBS.Tests/EnvironmentDataTests.cs b/src/ServiceControl.Transports.ASBS.Tests/EnvironmentDataTests.cs new file mode 100644 index 0000000000..59733fb0cc --- /dev/null +++ b/src/ServiceControl.Transports.ASBS.Tests/EnvironmentDataTests.cs @@ -0,0 +1,70 @@ +namespace ServiceControl.Transports.UnitTests.ASBS +{ + using System; + using System.Collections.Generic; + using System.Linq; + using NUnit.Framework; + using ServiceControl.Transports.ASBS; + + [TestFixture] + [NonParallelizable] + class EnvironmentDataTests + { + [TearDown] + public void TearDown() => Environment.SetEnvironmentVariable(TopologyVariable, null); + + [TestCase("Endpoint=sb://contoso.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=c2VjcmV0", "SharedAccessKey")] + [TestCase("Endpoint=sb://contoso.servicebus.windows.net/;Authentication=Managed Identity", "ManagedIdentity")] + [TestCase("Endpoint=sb://contoso.servicebus.windows.net/;Authentication=Managed Identity;ClientId=11111111-2222-3333-4444-555555555555", "ManagedIdentity")] + [TestCase("contoso.servicebus.windows.net", "DefaultAzureCredential")] + public void Should_report_the_authentication_mode(string connectionString, string expected) => + Assert.That(Read(connectionString)["Transport.Auth"], Is.EqualTo(expected)); + + [Test] + public void Should_report_defaults_when_the_connection_string_selects_no_options() + { + var data = Read("Endpoint=sb://contoso.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=c2VjcmV0"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.CertificateValidation"], Is.EqualTo("Default")); + Assert.That(data["Transport.AzureServiceBus.Topology"], Is.EqualTo("TopicPerEvent")); + Assert.That(data["Transport.AzureServiceBus.Partitioning"], Is.EqualTo("Disabled")); + Assert.That(data["Transport.AzureServiceBus.WebSockets"], Is.EqualTo("Disabled")); + Assert.That(data["Transport.AzureServiceBus.HierarchyNamespace"], Is.EqualTo("None")); + } + } + + [Test] + public void Should_report_selected_options_without_their_values() + { + var data = Read("Endpoint=sb://contoso.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=c2VjcmV0;TopicName=contoso-bundle;EnablePartitioning=true;TransportType=AmqpWebSockets;HierarchyNamespace=contoso-prod"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.AzureServiceBus.Topology"], Is.EqualTo("Migration")); + Assert.That(data["Transport.AzureServiceBus.Partitioning"], Is.EqualTo("Enabled")); + Assert.That(data["Transport.AzureServiceBus.WebSockets"], Is.EqualTo("Enabled")); + Assert.That(data["Transport.AzureServiceBus.HierarchyNamespace"], Is.EqualTo("Configured")); + Assert.That(string.Join("|", data.Values), Does.Not.Contain("contoso").IgnoreCase.And.Not.Contain("c2VjcmV0")); + } + } + + [Test] + public void Should_report_a_custom_topology() + { + Environment.SetEnvironmentVariable(TopologyVariable, "{}"); + + var data = Read("Endpoint=sb://contoso.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=c2VjcmV0"); + + Assert.That(data["Transport.AzureServiceBus.Topology"], Is.EqualTo("Custom")); + } + + static Dictionary Read(string connectionString) => + new ASBSTransportCustomization() + .GetEnvironmentData(new TransportSettings { ConnectionString = connectionString }) + .ToDictionary(datum => datum.Key, datum => datum.ReadValue()); + + const string TopologyVariable = "SERVICECONTROL_TRANSPORT_ASBS_TOPOLOGY"; + } +} diff --git a/src/ServiceControl.Transports.ASBS/ASBSTransportCustomization.cs b/src/ServiceControl.Transports.ASBS/ASBSTransportCustomization.cs index 778dec09d5..bfaee82c29 100644 --- a/src/ServiceControl.Transports.ASBS/ASBSTransportCustomization.cs +++ b/src/ServiceControl.Transports.ASBS/ASBSTransportCustomization.cs @@ -5,6 +5,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; + using Azure.Identity; using Azure.Messaging.ServiceBus; using Azure.Messaging.ServiceBus.Administration; using BrokerThroughput; @@ -17,6 +18,9 @@ public class ASBSTransportCustomization : TransportCustomization { + static readonly SettingsRootNamespace TopologySettingsNamespace = new("ServiceControl.Transport.ASBS"); + const string TopologySettingName = "Topology"; + protected override void CustomizeTransportForPrimaryEndpoint(EndpointConfiguration endpointConfiguration, AzureServiceBusTransport transportDefinition, TransportSettings transportSettings) => transportDefinition.TransportTransactionMode = TransportTransactionMode.SendsAtomicWithReceive; protected override void CustomizeTransportForAuditEndpoint(EndpointConfiguration endpointConfiguration, AzureServiceBusTransport transportDefinition, TransportSettings transportSettings) => transportDefinition.TransportTransactionMode = TransportTransactionMode.ReceiveOnly; @@ -24,6 +28,38 @@ public class ASBSTransportCustomization : TransportCustomization transportDefinition.TransportTransactionMode = TransportTransactionMode.ReceiveOnly; + protected override string GetAuthenticationMode(TransportSettings transportSettings) => + ConnectionStringParser.Parse(transportSettings.ConnectionString).AuthenticationMethod switch + { + SharedAccessSignatureAuthentication => "SharedAccessKey", + TokenCredentialAuthentication { Credential: ManagedIdentityCredential } => "ManagedIdentity", + TokenCredentialAuthentication => "DefaultAzureCredential", + _ => "Unknown" + }; + + protected override IEnumerable GetEnvironmentDataCore(TransportSettings transportSettings) + { + ConnectionSettings Parse() => ConnectionStringParser.Parse(transportSettings.ConnectionString); + + return + [ + new("Transport.AzureServiceBus.Topology", () => Topology(Parse())), + new("Transport.AzureServiceBus.Partitioning", () => Parse().EnablePartitioning ? "Enabled" : "Disabled"), + new("Transport.AzureServiceBus.WebSockets", () => Parse().UseWebSockets ? "Enabled" : "Disabled"), + new("Transport.AzureServiceBus.HierarchyNamespace", () => string.IsNullOrEmpty(Parse().HierarchyNamespace) ? "None" : "Configured") + ]; + } + + static string Topology(ConnectionSettings connectionSettings) + { + if (connectionSettings.TopicName != null) + { + return "Migration"; + } + + return SettingsReader.TryRead(TopologySettingsNamespace, TopologySettingName, out _) ? "Custom" : "TopicPerEvent"; + } + protected override AzureServiceBusTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly) { var connectionSettings = ConnectionStringParser.Parse(transportSettings.ConnectionString); @@ -66,7 +102,6 @@ protected override void AddTransportForPrimaryCore(IServiceCollection services, var connectionSettings = ConnectionStringParser.Parse(transportSettings.ConnectionString); TopicTopology selectedTopology; - var serviceBusRootNamespace = new SettingsRootNamespace("ServiceControl.Transport.ASBS"); if (connectionSettings.TopicName != null) { //Bundle name provided -> use migration topology @@ -80,7 +115,7 @@ protected override void AddTransportForPrimaryCore(IServiceCollection services, EventsToMigrateMap = [.. transportSettings.EventTypesPublished.Select(t => t.FullName)] }); } - else if (SettingsReader.TryRead(serviceBusRootNamespace, "Topology", out var topologyJson)) + else if (SettingsReader.TryRead(TopologySettingsNamespace, TopologySettingName, out var topologyJson)) { //Load topology from json selectedTopology = TopicTopology.FromOptions(JsonSerializer.Deserialize(topologyJson, TopologyOptionsSerializationContext.Default.TopologyOptions)); diff --git a/src/ServiceControl.Transports.PostgreSql.Tests/EnvironmentDataTests.cs b/src/ServiceControl.Transports.PostgreSql.Tests/EnvironmentDataTests.cs new file mode 100644 index 0000000000..0de2dd72bf --- /dev/null +++ b/src/ServiceControl.Transports.PostgreSql.Tests/EnvironmentDataTests.cs @@ -0,0 +1,49 @@ +namespace ServiceControl.Transport.Tests; + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Transports; +using Transports.PostgreSql; + +[TestFixture] +class EnvironmentDataTests +{ + [TestCase("Host=localhost;Username=nsb;Password=contoso-secret", "Password")] + [TestCase("Host=localhost;Username=nsb;Passfile=/home/nsb/.pgpass", "Password")] + [TestCase("Host=localhost;Username=nsb;SSL Certificate=/certs/client.crt", "ClientCertificate")] + [TestCase("Host=localhost;Username=nsb", "None")] + public void Should_report_the_authentication_mode(string connectionString, string expected) => + Assert.That(Read(connectionString)["Transport.Auth"], Is.EqualTo(expected)); + + [Test] + public void Should_report_default_schema_and_subscriptions_table() + { + var data = Read("Host=localhost;Username=nsb;Password=contoso-secret"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.PostgreSQL.QueueSchema"], Is.EqualTo("Default")); + Assert.That(data["Transport.PostgreSQL.SubscriptionsTable"], Is.EqualTo("Default")); + Assert.That(data["Transport.CertificateValidation"], Is.EqualTo("Default")); + } + } + + [Test] + public void Should_report_a_custom_schema_and_subscriptions_table_without_naming_them() + { + var data = Read("Host=localhost;Username=nsb;Password=contoso-secret;Queue Schema=contoso;Subscriptions Table=contoso.subscriptions"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.PostgreSQL.QueueSchema"], Is.EqualTo("Custom")); + Assert.That(data["Transport.PostgreSQL.SubscriptionsTable"], Is.EqualTo("Custom")); + Assert.That(string.Join("|", data.Values), Does.Not.Contain("contoso").IgnoreCase); + } + } + + static Dictionary Read(string connectionString) => + new PostgreSqlTransportCustomization() + .GetEnvironmentData(new TransportSettings { ConnectionString = connectionString }) + .ToDictionary(datum => datum.Key, datum => datum.ReadValue()); +} diff --git a/src/ServiceControl.Transports.PostgreSql/PostgreSqlTransportCustomization.cs b/src/ServiceControl.Transports.PostgreSql/PostgreSqlTransportCustomization.cs index 4523792f18..75f16e3a04 100644 --- a/src/ServiceControl.Transports.PostgreSql/PostgreSqlTransportCustomization.cs +++ b/src/ServiceControl.Transports.PostgreSql/PostgreSqlTransportCustomization.cs @@ -1,10 +1,12 @@ namespace ServiceControl.Transports.PostgreSql; +using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; using BrokerThroughput; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Npgsql; using NServiceBus; using NServiceBus.Transport.PostgreSql; using ServiceControl.Infrastructure; @@ -42,6 +44,32 @@ protected override void AddTransportForMonitoringCore(IServiceCollection service services.AddHostedService(provider => provider.GetRequiredService()); } + protected override string GetAuthenticationMode(TransportSettings transportSettings) + { + var builder = new NpgsqlConnectionStringBuilder(transportSettings.ConnectionString.RemoveCustomConnectionStringParts(out _, out _)); + + if (!string.IsNullOrEmpty(builder.SslCertificate)) + { + return "ClientCertificate"; + } + + return !string.IsNullOrEmpty(builder.Password) || !string.IsNullOrEmpty(builder.Passfile) ? "Password" : "None"; + } + + protected override IEnumerable GetEnvironmentDataCore(TransportSettings transportSettings) => + [ + new("Transport.PostgreSQL.QueueSchema", () => + { + transportSettings.ConnectionString.RemoveCustomConnectionStringParts(out var schema, out _); + return schema is null ? "Default" : "Custom"; + }), + new("Transport.PostgreSQL.SubscriptionsTable", () => + { + transportSettings.ConnectionString.RemoveCustomConnectionStringParts(out _, out var subscriptionsTable); + return subscriptionsTable is null ? "Default" : "Custom"; + }) + ]; + protected override PostgreSqlTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly) { var connectionString = transportSettings.ConnectionString.RemoveCustomConnectionStringParts(out var customSchema, out var subscriptionsTableSetting); diff --git a/src/ServiceControl.Transports.RabbitMQ/RabbitMQConventionalRoutingTransportCustomization.cs b/src/ServiceControl.Transports.RabbitMQ/RabbitMQConventionalRoutingTransportCustomization.cs index 0a096c5222..d63328d0e2 100644 --- a/src/ServiceControl.Transports.RabbitMQ/RabbitMQConventionalRoutingTransportCustomization.cs +++ b/src/ServiceControl.Transports.RabbitMQ/RabbitMQConventionalRoutingTransportCustomization.cs @@ -1,6 +1,7 @@ namespace ServiceControl.Transports.RabbitMQ { using System; + using System.Collections.Generic; using System.Linq; using BrokerThroughput; using Microsoft.Extensions.DependencyInjection; @@ -32,6 +33,15 @@ ManagementClient Get() } } + protected override string GetAuthenticationMode(TransportSettings transportSettings) => + RabbitMQTransportExtensions.GetAuthenticationMode(transportSettings.ConnectionString); + + protected override bool RelaxesCertificateValidation(TransportSettings transportSettings) => + RabbitMQTransportExtensions.RelaxesCertificateValidation(transportSettings.ConnectionString); + + protected override IEnumerable GetEnvironmentDataCore(TransportSettings transportSettings) => + RabbitMQTransportExtensions.GetEnvironmentData(transportSettings.ConnectionString); + protected override void CustomizeTransportForPrimaryEndpoint(EndpointConfiguration endpointConfiguration, RabbitMQTransport transportDefinition, TransportSettings transportSettings) => transport = transportDefinition; protected override void CustomizeTransportForAuditEndpoint(EndpointConfiguration endpointConfiguration, RabbitMQTransport transportDefinition, TransportSettings transportSettings) => transport = transportDefinition; diff --git a/src/ServiceControl.Transports.RabbitMQ/RabbitMQDirectRoutingTransportCustomization.cs b/src/ServiceControl.Transports.RabbitMQ/RabbitMQDirectRoutingTransportCustomization.cs index 2d497877d0..df806869fc 100644 --- a/src/ServiceControl.Transports.RabbitMQ/RabbitMQDirectRoutingTransportCustomization.cs +++ b/src/ServiceControl.Transports.RabbitMQ/RabbitMQDirectRoutingTransportCustomization.cs @@ -1,6 +1,7 @@ namespace ServiceControl.Transports.RabbitMQ { using System; + using System.Collections.Generic; using System.Linq; using BrokerThroughput; using Microsoft.Extensions.DependencyInjection; @@ -32,6 +33,15 @@ ManagementClient Get() } } + protected override string GetAuthenticationMode(TransportSettings transportSettings) => + RabbitMQTransportExtensions.GetAuthenticationMode(transportSettings.ConnectionString); + + protected override bool RelaxesCertificateValidation(TransportSettings transportSettings) => + RabbitMQTransportExtensions.RelaxesCertificateValidation(transportSettings.ConnectionString); + + protected override IEnumerable GetEnvironmentDataCore(TransportSettings transportSettings) => + RabbitMQTransportExtensions.GetEnvironmentData(transportSettings.ConnectionString); + protected override void CustomizeTransportForPrimaryEndpoint(EndpointConfiguration endpointConfiguration, RabbitMQTransport transportDefinition, TransportSettings transportSettings) => transport = transportDefinition; protected override void CustomizeTransportForAuditEndpoint(EndpointConfiguration endpointConfiguration, RabbitMQTransport transportDefinition, TransportSettings transportSettings) => transport = transportDefinition; diff --git a/src/ServiceControl.Transports.RabbitMQ/RabbitMQTransportExtensions.cs b/src/ServiceControl.Transports.RabbitMQ/RabbitMQTransportExtensions.cs index 1f5b2ed033..3398dbcc26 100644 --- a/src/ServiceControl.Transports.RabbitMQ/RabbitMQTransportExtensions.cs +++ b/src/ServiceControl.Transports.RabbitMQ/RabbitMQTransportExtensions.cs @@ -11,14 +11,7 @@ static class RabbitMQTransportExtensions { public static void ApplySettingsFromConnectionString(this RabbitMQTransport transport, string connectionString) { - if (connectionString.StartsWith("amqp", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - var dictionary = new DbConnectionStringBuilder { ConnectionString = connectionString } - .OfType>() - .ToDictionary(pair => pair.Key, pair => pair.Value.ToString(), StringComparer.OrdinalIgnoreCase); + var dictionary = ReadConnectionStringOptions(connectionString); if (dictionary.TryGetValue("ValidateDeliveryLimits", out var validateDeliveryLimitsString)) { @@ -38,9 +31,41 @@ public static void ApplySettingsFromConnectionString(this RabbitMQTransport tran transport.ValidateRemoteCertificate = !disableRemoteCertificateValidation; } - if (dictionary.TryGetValue("UseExternalAuthMechanism", out var useExternalAuthMechanismString) && bool.TryParse(useExternalAuthMechanismString, out var useExternalAuthMechanism) && useExternalAuthMechanism) + if (UsesExternalAuthMechanism(dictionary)) { transport.AuthMechanisms = [new ExternalMechanismFactory()]; } } + + public static IEnumerable GetEnvironmentData(string connectionString) => + [ + new("Transport.RabbitMQ.DeliveryLimitValidation", () => + { + var options = ReadConnectionStringOptions(connectionString); + var validates = !options.TryGetValue("ValidateDeliveryLimits", out var value) || (bool.TryParse(value, out var parsed) && parsed); + return validates ? "Enabled" : "Disabled"; + }), + new("Transport.RabbitMQ.ManagementApi", () => ReadConnectionStringOptions(connectionString).ContainsKey("ManagementApiUrl") ? "Configured" : "Default") + ]; + + public static string GetAuthenticationMode(string connectionString) => + UsesExternalAuthMechanism(ReadConnectionStringOptions(connectionString)) ? "ExternalCertificate" : "Password"; + + public static bool RelaxesCertificateValidation(string connectionString) => + ReadConnectionStringOptions(connectionString).TryGetValue("DisableRemoteCertificateValidation", out var value) && bool.TryParse(value, out var disabled) && disabled; + + static bool UsesExternalAuthMechanism(Dictionary options) => + options.TryGetValue("UseExternalAuthMechanism", out var value) && bool.TryParse(value, out var useExternalAuthMechanism) && useExternalAuthMechanism; + + static Dictionary ReadConnectionStringOptions(string connectionString) + { + if (connectionString.StartsWith("amqp", StringComparison.OrdinalIgnoreCase)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return new DbConnectionStringBuilder { ConnectionString = connectionString } + .OfType>() + .ToDictionary(pair => pair.Key, pair => pair.Value.ToString(), StringComparer.OrdinalIgnoreCase); + } } diff --git a/src/ServiceControl.Transports.RabbitMQClassicConventionalRouting.Tests/EnvironmentDataTests.cs b/src/ServiceControl.Transports.RabbitMQClassicConventionalRouting.Tests/EnvironmentDataTests.cs new file mode 100644 index 0000000000..f97f4cf58b --- /dev/null +++ b/src/ServiceControl.Transports.RabbitMQClassicConventionalRouting.Tests/EnvironmentDataTests.cs @@ -0,0 +1,51 @@ +namespace ServiceControl.Transport.Tests; + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Transports; +using Transports.RabbitMQ; + +[TestFixture] +class EnvironmentDataTests +{ + [TestCase("host=localhost")] + [TestCase("amqp://guest:guest@localhost:5672")] + public void Should_report_defaults_when_the_connection_string_selects_no_options(string connectionString) + { + var data = Read(new RabbitMQClassicConventionalRoutingTransportCustomization(), connectionString); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.Auth"], Is.EqualTo("Password")); + Assert.That(data["Transport.CertificateValidation"], Is.EqualTo("Default")); + Assert.That(data["Transport.RabbitMQ.DeliveryLimitValidation"], Is.EqualTo("Enabled")); + Assert.That(data["Transport.RabbitMQ.ManagementApi"], Is.EqualTo("Default")); + } + } + + [Test] + public void Should_report_selected_options_on_either_routing_topology_without_their_values() + { + const string connectionString = "host=rabbit.contoso.local;UseExternalAuthMechanism=true;DisableRemoteCertificateValidation=true;ValidateDeliveryLimits=false;ManagementApiUrl=https://rabbit.contoso.local:15671"; + + foreach (var customization in new ITransportCustomization[] { new RabbitMQClassicConventionalRoutingTransportCustomization(), new RabbitMQQuorumDirectRoutingTransportCustomization() }) + { + var data = Read(customization, connectionString); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.Auth"], Is.EqualTo("ExternalCertificate")); + Assert.That(data["Transport.CertificateValidation"], Is.EqualTo("Relaxed")); + Assert.That(data["Transport.RabbitMQ.DeliveryLimitValidation"], Is.EqualTo("Disabled")); + Assert.That(data["Transport.RabbitMQ.ManagementApi"], Is.EqualTo("Configured")); + Assert.That(string.Join("|", data.Values), Does.Not.Contain("contoso").IgnoreCase); + } + } + } + + static Dictionary Read(ITransportCustomization customization, string connectionString) => + customization + .GetEnvironmentData(new TransportSettings { ConnectionString = connectionString }) + .ToDictionary(datum => datum.Key, datum => datum.ReadValue()); +} diff --git a/src/ServiceControl.Transports.SQS.Tests/EnvironmentDataTests.cs b/src/ServiceControl.Transports.SQS.Tests/EnvironmentDataTests.cs new file mode 100644 index 0000000000..75f1c91e18 --- /dev/null +++ b/src/ServiceControl.Transports.SQS.Tests/EnvironmentDataTests.cs @@ -0,0 +1,53 @@ +namespace ServiceControl.Transport.Tests; + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Transports; +using Transports.SQS; + +[TestFixture] +class EnvironmentDataTests +{ + [Test] + public void Should_report_defaults_when_the_connection_string_carries_only_a_region() + { + var data = Read("Region=eu-west-1"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.Auth"], Is.EqualTo("IamRole")); + Assert.That(data["Transport.CertificateValidation"], Is.EqualTo("Default")); + Assert.That(data["Transport.AmazonSQS.NamePrefixes"], Is.EqualTo("None")); + Assert.That(data["Transport.AmazonSQS.LargeMessageBucket"], Is.EqualTo("None")); + Assert.That(data["Transport.AmazonSQS.MessageWrapping"], Is.EqualTo("Enabled")); + Assert.That(data["Transport.AmazonSQS.ReservedBytesInMessageSize"], Is.EqualTo("Default")); + } + } + + [Test] + public void Should_report_selected_options_without_their_values() + { + var data = Read("AccessKeyId=AKIACONTOSO;SecretAccessKey=contoso-secret;Region=eu-west-1;QueueNamePrefix=contoso-;S3BucketForLargeMessages=contoso-bodies;DoNotWrapOutgoingMessages=true;ReservedBytesInMessageSize=1024"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.Auth"], Is.EqualTo("StaticCredentials")); + Assert.That(data["Transport.AmazonSQS.NamePrefixes"], Is.EqualTo("Queue")); + Assert.That(data["Transport.AmazonSQS.LargeMessageBucket"], Is.EqualTo("Configured")); + Assert.That(data["Transport.AmazonSQS.MessageWrapping"], Is.EqualTo("Disabled")); + Assert.That(data["Transport.AmazonSQS.ReservedBytesInMessageSize"], Is.EqualTo("1024")); + Assert.That(string.Join("|", data.Values), Does.Not.Contain("contoso").IgnoreCase); + } + } + + [TestCase("QueueNamePrefix=a-;TopicNamePrefix=b-", "QueueAndTopic")] + [TestCase("TopicNamePrefix=b-", "Topic")] + public void Should_report_which_name_prefixes_are_set(string prefixes, string expected) => + Assert.That(Read($"Region=eu-west-1;{prefixes}")["Transport.AmazonSQS.NamePrefixes"], Is.EqualTo(expected)); + + static Dictionary Read(string connectionString) => + new SQSTransportCustomization() + .GetEnvironmentData(new TransportSettings { ConnectionString = connectionString }) + .ToDictionary(datum => datum.Key, datum => datum.ReadValue()); +} diff --git a/src/ServiceControl.Transports.SQS/SQSTransportConnectionString.cs b/src/ServiceControl.Transports.SQS/SQSTransportConnectionString.cs index c1a3e2a678..71bce5cc03 100644 --- a/src/ServiceControl.Transports.SQS/SQSTransportConnectionString.cs +++ b/src/ServiceControl.Transports.SQS/SQSTransportConnectionString.cs @@ -53,12 +53,14 @@ public SQSTransportConnectionString(string connectionString) } - if (builder.TryGetValue("ReservedBytesInMessageSize", out object reservedBytes)) + if (builder.TryGetValue(ReservedBytesInMessageSizeKey, out object reservedBytes)) { ReservedBytesInMessageSize = Convert.ToInt32(reservedBytes); } } + public const string ReservedBytesInMessageSizeKey = "ReservedBytesInMessageSize"; + public string AccessKey { get; } public string SecretKey { get; } public string Region { get; } diff --git a/src/ServiceControl.Transports.SQS/SQSTransportCustomization.cs b/src/ServiceControl.Transports.SQS/SQSTransportCustomization.cs index cf844e1961..aa43044c76 100644 --- a/src/ServiceControl.Transports.SQS/SQSTransportCustomization.cs +++ b/src/ServiceControl.Transports.SQS/SQSTransportCustomization.cs @@ -1,6 +1,9 @@ namespace ServiceControl.Transports.SQS { using System; + using System.Collections.Generic; + using System.Data.Common; + using System.Globalization; using System.Linq; using Amazon; using Amazon.Runtime; @@ -41,6 +44,36 @@ protected override void AddTransportForMonitoringCore(IServiceCollection service services.AddHostedService(provider => provider.GetRequiredService()); } + protected override string GetAuthenticationMode(TransportSettings transportSettings) + { + var connectionString = new SQSTransportConnectionString(transportSettings.ConnectionString); + return connectionString.AccessKey != null || connectionString.SecretKey != null ? "StaticCredentials" : "IamRole"; + } + + protected override IEnumerable GetEnvironmentDataCore(TransportSettings transportSettings) + { + SQSTransportConnectionString Parse() => new(transportSettings.ConnectionString); + + return + [ + new("Transport.AmazonSQS.NamePrefixes", () => NamePrefixes(Parse())), + new("Transport.AmazonSQS.LargeMessageBucket", () => string.IsNullOrEmpty(Parse().S3BucketForLargeMessages) ? "None" : "Configured"), + new("Transport.AmazonSQS.MessageWrapping", () => Parse().DoNotWrapOutgoingMessages ? "Disabled" : "Enabled"), + new("Transport.AmazonSQS.ReservedBytesInMessageSize", () => new DbConnectionStringBuilder { ConnectionString = transportSettings.ConnectionString }.ContainsKey(SQSTransportConnectionString.ReservedBytesInMessageSizeKey) + ? Parse().ReservedBytesInMessageSize.ToString(CultureInfo.InvariantCulture) + : "Default") + ]; + } + + static string NamePrefixes(SQSTransportConnectionString connectionString) => + (string.IsNullOrEmpty(connectionString.QueueNamePrefix), string.IsNullOrEmpty(connectionString.TopicNamePrefix)) switch + { + (false, false) => "QueueAndTopic", + (false, true) => "Queue", + (true, false) => "Topic", + _ => "None" + }; + protected override SqsTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly) { var builder = new SQSTransportConnectionString(transportSettings.ConnectionString); diff --git a/src/ServiceControl.Transports.SqlServer.Tests/EnvironmentDataTests.cs b/src/ServiceControl.Transports.SqlServer.Tests/EnvironmentDataTests.cs new file mode 100644 index 0000000000..37bf1740e5 --- /dev/null +++ b/src/ServiceControl.Transports.SqlServer.Tests/EnvironmentDataTests.cs @@ -0,0 +1,50 @@ +namespace ServiceControl.Transport.Tests; + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Transports; +using Transports.SqlServer; + +[TestFixture] +class EnvironmentDataTests +{ + [TestCase("Server=.;Database=nsb;Integrated Security=true", "Integrated")] + [TestCase("Server=.;Database=nsb;User Id=sa;Password=contoso-secret", "SqlPassword")] + [TestCase("Server=tcp:contoso.database.windows.net;Database=nsb;Authentication=Active Directory Managed Identity", "EntraId")] + [TestCase("Server=tcp:contoso.database.windows.net;Database=nsb;Authentication=Active Directory Default", "EntraId")] + public void Should_report_the_authentication_mode(string connectionString, string expected) => + Assert.That(Read(connectionString)["Transport.Auth"], Is.EqualTo(expected)); + + [Test] + public void Should_report_default_schema_and_subscriptions_table() + { + var data = Read("Server=.;Database=nsb;Integrated Security=true"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.SQLServer.QueueSchema"], Is.EqualTo("Default")); + Assert.That(data["Transport.SQLServer.SubscriptionsTable"], Is.EqualTo("Default")); + Assert.That(data["Transport.CertificateValidation"], Is.EqualTo("Default")); + } + } + + [Test] + public void Should_report_a_custom_schema_and_subscriptions_table_without_naming_them() + { + var data = Read("Server=.;Database=nsb;User Id=sa;Password=contoso-secret;Queue Schema=contoso;Subscriptions Table=contoso.subscriptions"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.Auth"], Is.EqualTo("SqlPassword")); + Assert.That(data["Transport.SQLServer.QueueSchema"], Is.EqualTo("Custom")); + Assert.That(data["Transport.SQLServer.SubscriptionsTable"], Is.EqualTo("Custom")); + Assert.That(string.Join("|", data.Values), Does.Not.Contain("contoso").IgnoreCase); + } + } + + static Dictionary Read(string connectionString) => + new SqlServerTransportCustomization() + .GetEnvironmentData(new TransportSettings { ConnectionString = connectionString }) + .ToDictionary(datum => datum.Key, datum => datum.ReadValue()); +} diff --git a/src/ServiceControl.Transports.SqlServer/SqlServerTransportCustomization.cs b/src/ServiceControl.Transports.SqlServer/SqlServerTransportCustomization.cs index 4bed774adb..c018a667f7 100644 --- a/src/ServiceControl.Transports.SqlServer/SqlServerTransportCustomization.cs +++ b/src/ServiceControl.Transports.SqlServer/SqlServerTransportCustomization.cs @@ -1,8 +1,10 @@ namespace ServiceControl.Transports.SqlServer { + using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; using BrokerThroughput; + using Microsoft.Data.SqlClient; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NServiceBus; @@ -50,6 +52,32 @@ protected override void AddTransportForMonitoringCore(IServiceCollection service services.AddHostedService(provider => provider.GetRequiredService()); } + protected override string GetAuthenticationMode(TransportSettings transportSettings) + { + var builder = new SqlConnectionStringBuilder(transportSettings.ConnectionString.RemoveCustomConnectionStringParts(out _, out _)); + + if (builder.Authentication is SqlAuthenticationMethod.NotSpecified) + { + return builder.IntegratedSecurity ? "Integrated" : "SqlPassword"; + } + + return builder.Authentication is SqlAuthenticationMethod.SqlPassword ? "SqlPassword" : "EntraId"; + } + + protected override IEnumerable GetEnvironmentDataCore(TransportSettings transportSettings) => + [ + new("Transport.SQLServer.QueueSchema", () => + { + transportSettings.ConnectionString.RemoveCustomConnectionStringParts(out var schema, out _); + return schema is null ? "Default" : "Custom"; + }), + new("Transport.SQLServer.SubscriptionsTable", () => + { + transportSettings.ConnectionString.RemoveCustomConnectionStringParts(out _, out var subscriptionsTable); + return subscriptionsTable is null ? "Default" : "Custom"; + }) + ]; + protected override SqlServerTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly) { var connectionString = transportSettings.ConnectionString.RemoveCustomConnectionStringParts(out var customSchema, out var subscriptionsTableSetting); diff --git a/src/ServiceControl.Transports.Tests/ApprovalFiles/APIApprovals.ServiceControlTransport.approved.txt b/src/ServiceControl.Transports.Tests/ApprovalFiles/APIApprovals.ServiceControlTransport.approved.txt index 7963f24901..00e84fc51b 100644 --- a/src/ServiceControl.Transports.Tests/ApprovalFiles/APIApprovals.ServiceControlTransport.approved.txt +++ b/src/ServiceControl.Transports.Tests/ApprovalFiles/APIApprovals.ServiceControlTransport.approved.txt @@ -31,6 +31,7 @@ namespace ServiceControl.Transports void CustomizeAuditEndpoint(NServiceBus.EndpointConfiguration endpointConfiguration, ServiceControl.Transports.TransportSettings transportSettings); void CustomizeMonitoringEndpoint(NServiceBus.EndpointConfiguration endpointConfiguration, ServiceControl.Transports.TransportSettings transportSettings); void CustomizePrimaryEndpoint(NServiceBus.EndpointConfiguration endpointConfiguration, ServiceControl.Transports.TransportSettings transportSettings); + System.Collections.Generic.IEnumerable GetEnvironmentData(ServiceControl.Transports.TransportSettings transportSettings); System.Threading.Tasks.Task ProvisionQueues(ServiceControl.Transports.TransportSettings transportSettings, System.Collections.Generic.IEnumerable additionalQueues, System.Threading.CancellationToken cancellationToken = default); string ToTransportQualifiedQueueName(string queueName); } @@ -59,10 +60,20 @@ namespace ServiceControl.Transports protected abstract void CustomizeTransportForAuditEndpoint(NServiceBus.EndpointConfiguration endpointConfiguration, TTransport transportDefinition, ServiceControl.Transports.TransportSettings transportSettings); protected abstract void CustomizeTransportForMonitoringEndpoint(NServiceBus.EndpointConfiguration endpointConfiguration, TTransport transportDefinition, ServiceControl.Transports.TransportSettings transportSettings); protected abstract void CustomizeTransportForPrimaryEndpoint(NServiceBus.EndpointConfiguration endpointConfiguration, TTransport transportDefinition, ServiceControl.Transports.TransportSettings transportSettings); + protected virtual string GetAuthenticationMode(ServiceControl.Transports.TransportSettings transportSettings) { } + public System.Collections.Generic.IEnumerable GetEnvironmentData(ServiceControl.Transports.TransportSettings transportSettings) { } + protected virtual System.Collections.Generic.IEnumerable GetEnvironmentDataCore(ServiceControl.Transports.TransportSettings transportSettings) { } public virtual System.Threading.Tasks.Task ProvisionQueues(ServiceControl.Transports.TransportSettings transportSettings, System.Collections.Generic.IEnumerable additionalQueues, System.Threading.CancellationToken cancellationToken = default) { } + protected virtual bool RelaxesCertificateValidation(ServiceControl.Transports.TransportSettings transportSettings) { } public string ToTransportQualifiedQueueName(string queueName) { } protected virtual string ToTransportQualifiedQueueNameCore(string queueName) { } } + public sealed class TransportEnvironmentDatum : System.IEquatable + { + public TransportEnvironmentDatum(string Key, System.Func ReadValue) { } + public string Key { get; init; } + public System.Func ReadValue { get; init; } + } public static class TransportFactory { public static ServiceControl.Transports.ITransportCustomization Create(ServiceControl.Transports.TransportSettings settings) { } diff --git a/src/ServiceControl.Transports/TransportCustomization.cs b/src/ServiceControl.Transports/TransportCustomization.cs index a03fcb2ee1..0294d438a6 100644 --- a/src/ServiceControl.Transports/TransportCustomization.cs +++ b/src/ServiceControl.Transports/TransportCustomization.cs @@ -26,6 +26,8 @@ public interface ITransportCustomization string ToTransportQualifiedQueueName(string queueName); Task CreateTransportInfrastructure(string name, TransportSettings transportSettings, OnMessage onMessage = null, OnError onError = null, Func onCriticalError = null, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly, CancellationToken cancellationToken = default); + + IEnumerable GetEnvironmentData(TransportSettings transportSettings) => []; } public abstract class TransportCustomization : ITransportCustomization where TTransport : TransportDefinition @@ -48,6 +50,19 @@ protected virtual void AddTransportForPrimaryCore(IServiceCollection services, T { } + public IEnumerable GetEnvironmentData(TransportSettings transportSettings) => + [ + new("Transport.Auth", () => GetAuthenticationMode(transportSettings)), + new("Transport.CertificateValidation", () => RelaxesCertificateValidation(transportSettings) ? "Relaxed" : "Default"), + .. GetEnvironmentDataCore(transportSettings) + ]; + + protected virtual string GetAuthenticationMode(TransportSettings transportSettings) => "NotApplicable"; + + protected virtual bool RelaxesCertificateValidation(TransportSettings transportSettings) => false; + + protected virtual IEnumerable GetEnvironmentDataCore(TransportSettings transportSettings) => []; + public void AddTransportForAudit(IServiceCollection services, TransportSettings transportSettings) { services.AddSingleton(this); diff --git a/src/ServiceControl.Transports/TransportEnvironmentDatum.cs b/src/ServiceControl.Transports/TransportEnvironmentDatum.cs new file mode 100644 index 0000000000..52d7508af9 --- /dev/null +++ b/src/ServiceControl.Transports/TransportEnvironmentDatum.cs @@ -0,0 +1,6 @@ +namespace ServiceControl.Transports +{ + using System; + + public sealed record TransportEnvironmentDatum(string Key, Func ReadValue); +} diff --git a/src/ServiceControl.UnitTests/Licensing/ErrorInstanceTuningEnvironmentDataProviderTests.cs b/src/ServiceControl.UnitTests/Licensing/ErrorInstanceTuningEnvironmentDataProviderTests.cs new file mode 100644 index 0000000000..7341595e2f --- /dev/null +++ b/src/ServiceControl.UnitTests/Licensing/ErrorInstanceTuningEnvironmentDataProviderTests.cs @@ -0,0 +1,84 @@ +namespace ServiceControl.UnitTests.Licensing; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Particular.ServiceControl; +using ServiceBus.Management.Infrastructure.Settings; + +[TestFixture] +[NonParallelizable] +class ErrorInstanceTuningEnvironmentDataProviderTests +{ + [TearDown] + public void TearDown() + { + foreach (var variable in Variables) + { + Environment.SetEnvironmentVariable(variable, null); + } + } + + [Test] + public async Task Should_report_default_for_every_setting_left_unset() + { + var data = await GetData(); + + Assert.That(data.Values, Has.All.EqualTo("Default")); + } + + [Test] + public async Task Should_report_the_configured_value_in_the_unit_its_key_names() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_HEARTBEATGRACEPERIOD", "00:01:30"); + Environment.SetEnvironmentVariable("SERVICECONTROL_ERRORINGESTIONBATCHTIMEOUT", "00:00:00.250"); + Environment.SetEnvironmentVariable("SERVICECONTROL_MAXIMUMCONCURRENCYLEVEL", "64"); + Environment.SetEnvironmentVariable("SERVICECONTROL_RETRYHISTORYDEPTH", "20"); + Environment.SetEnvironmentVariable("SERVICECONTROL_SHUTDOWNTIMEOUT", "00:02:00"); + + var data = await GetData(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Heartbeats.GracePeriodSeconds"], Is.EqualTo("90")); + Assert.That(data["Ingestion.Error.BatchTimeoutMs"], Is.EqualTo("250")); + Assert.That(data["Ingestion.Error.MaxConcurrency"], Is.EqualTo("64")); + Assert.That(data["Recoverability.RetryHistoryDepth"], Is.EqualTo("20")); + Assert.That(data["Host.ShutdownTimeoutSeconds"], Is.EqualTo("120")); + Assert.That(data["Ingestion.Error.BatchSize"], Is.EqualTo("Default")); + } + } + + [Test] + public async Task Should_report_a_value_set_to_the_default_as_configured() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_RETRYHISTORYDEPTH", "10"); + + var data = await GetData(); + + Assert.That(data["Recoverability.RetryHistoryDepth"], Is.EqualTo("10")); + } + + static async Task> GetData() + { + var data = new Dictionary(); + + foreach (var datum in new ErrorInstanceTuningEnvironmentDataProvider(new Settings()).GetData()) + { + data[datum.Key] = await datum.ReadValue(CancellationToken.None); + } + + return data; + } + + static readonly string[] Variables = + [ + "SERVICECONTROL_HEARTBEATGRACEPERIOD", + "SERVICECONTROL_ERRORINGESTIONBATCHTIMEOUT", + "SERVICECONTROL_MAXIMUMCONCURRENCYLEVEL", + "SERVICECONTROL_RETRYHISTORYDEPTH", + "SERVICECONTROL_SHUTDOWNTIMEOUT" + ]; +} diff --git a/src/ServiceControl.UnitTests/Licensing/ServiceControlErrorInstanceEnvironmentDataProviderTests.cs b/src/ServiceControl.UnitTests/Licensing/ServiceControlErrorInstanceEnvironmentDataProviderTests.cs new file mode 100644 index 0000000000..bdcb266565 --- /dev/null +++ b/src/ServiceControl.UnitTests/Licensing/ServiceControlErrorInstanceEnvironmentDataProviderTests.cs @@ -0,0 +1,226 @@ +namespace ServiceControl.UnitTests.Licensing; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using NUnit.Framework; +using Particular.ServiceControl; +using ServiceBus.Management.Infrastructure.Settings; +using ServiceControl.Notifications; +using ServiceControl.Persistence; + +[TestFixture] +[NonParallelizable] +class ServiceControlErrorInstanceEnvironmentDataProviderTests +{ + [TearDown] + public void TearDown() + { + foreach (var variable in Variables) + { + Environment.SetEnvironmentVariable(variable, null); + } + } + + [Test] + public async Task Should_report_default_security_posture_when_nothing_is_configured() + { + var data = await GetData(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Security.TokenValidation"], Is.EqualTo("NotApplicable")); + Assert.That(data["Security.ClaimMapping"], Is.EqualTo("NotApplicable")); + Assert.That(data["Security.ServicePulseOfflineAccess"], Is.EqualTo("NotApplicable")); + Assert.That(data["Security.HttpsHardening"], Is.EqualTo("None")); + Assert.That(data["Security.Cors"], Is.EqualTo("AnyOrigin")); + Assert.That(data["Security.ForwardedHeaders"], Is.EqualTo("TrustAllProxies")); + } + } + + [Test] + public async Task Should_report_relaxed_token_validation_and_custom_claims_without_naming_them() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_VALIDATECONFIG", "false"); + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ENABLED", "true"); + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA", "false"); + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ROLESCLAIM", "contoso_roles"); + + var data = await GetData(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Security.TokenValidation"], Is.EqualTo("Relaxed")); + Assert.That(data["Security.ClaimMapping"], Is.EqualTo("Custom")); + Assert.That(data["Security.ServicePulseOfflineAccess"], Is.EqualTo("Enabled")); + Assert.That(data["Features.ConfigurationValidation"], Is.EqualTo("Disabled")); + Assert.That(Reported(data), Does.Not.Contain("contoso").IgnoreCase); + } + } + + [Test] + public async Task Should_report_restricted_web_access_without_naming_origins_or_proxies() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_CORS_ALLOWEDORIGINS", "https://pulse.contoso.com"); + Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "10.0.0.7"); + Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS", "true"); + Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_ENABLEHSTS", "true"); + + var data = await GetData(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Security.Cors"], Is.EqualTo("Restricted")); + Assert.That(data["Security.ForwardedHeaders"], Is.EqualTo("KnownProxies")); + Assert.That(data["Security.HttpsHardening"], Is.EqualTo("RedirectAndHsts")); + Assert.That(Reported(data), Does.Not.Contain("contoso").IgnoreCase.And.Not.Contain("10.0.0.7")); + } + } + + [Test] + public async Task Should_report_forwarded_headers_disabled() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_ENABLED", "false"); + + var data = await GetData(); + + Assert.That(data["Security.ForwardedHeaders"], Is.EqualTo("Disabled")); + } + + [Test] + public async Task Should_report_servicepulse_choices_as_not_applicable_when_it_is_not_integrated() + { + var data = await GetData(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["ServicePulse.MonitoringUrl"], Is.EqualTo("NotApplicable")); + Assert.That(data["ServicePulse.DefaultRoute"], Is.EqualTo("NotApplicable")); + Assert.That(data["ServicePulse.ShowPendingRetry"], Is.EqualTo("NotApplicable")); + } + } + + [Test] + public async Task Should_report_integrated_servicepulse_choices_without_their_values() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE", "true"); + Environment.SetEnvironmentVariable("MONITORING_URL", "!"); + Environment.SetEnvironmentVariable("DEFAULT_ROUTE", "/contoso-failed-messages"); + Environment.SetEnvironmentVariable("SHOW_PENDING_RETRY", "true"); + + var data = await GetData(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["ServicePulse.MonitoringUrl"], Is.EqualTo("Disabled")); + Assert.That(data["ServicePulse.DefaultRoute"], Is.EqualTo("Custom")); + Assert.That(data["ServicePulse.ShowPendingRetry"], Is.EqualTo("Enabled")); + Assert.That(Reported(data), Does.Not.Contain("contoso").IgnoreCase); + } + } + + [Test] + public async Task Should_report_a_custom_monitoring_url_for_integrated_servicepulse() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE", "true"); + Environment.SetEnvironmentVariable("MONITORING_URL", "http://monitoring.contoso.local:33633/"); + + var data = await GetData(); + + Assert.That(data["ServicePulse.MonitoringUrl"], Is.EqualTo("Custom")); + } + + [Test] + public async Task Should_report_feature_switches_from_configuration() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_INGESTERRORMESSAGES", "false"); + Environment.SetEnvironmentVariable("SERVICECONTROL_VIRTUALDIRECTORY", "contoso"); + + var data = await GetData(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Features.ErrorIngestion"], Is.EqualTo("Disabled")); + Assert.That(data["Host.VirtualDirectory"], Is.EqualTo("Configured")); + Assert.That(Reported(data), Does.Not.Contain("contoso").IgnoreCase); + } + } + + [Test] + public async Task Should_report_otlp_metrics_export_without_the_endpoint() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://collector.contoso.local:4317" }) + .Build(); + + var data = await GetData(configuration); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Telemetry.OtlpMetrics"], Is.EqualTo("Enabled")); + Assert.That(Reported(data), Does.Not.Contain("contoso").IgnoreCase); + } + } + + [Test] + public async Task Should_report_logging_as_fixed_names() + { + var data = await GetData(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Logging.Level"], Is.AnyOf("Trace", "Debug", "Information", "Warning", "Error", "Critical", "None")); + Assert.That(data["Logging.Providers"].Split(','), Is.SubsetOf(new[] { "NLog", "Seq", "Otlp", "None" })); + Assert.That(data["Telemetry.OtlpMetrics"], Is.EqualTo("Disabled")); + } + } + + static async Task> GetData(IConfiguration configuration = null) + { + var provider = new ServiceControlErrorInstanceEnvironmentDataProvider( + new Settings(), + new StoredNotifications(), + configuration ?? new ConfigurationBuilder().Build()); + + var data = new Dictionary(); + + foreach (var datum in provider.GetData()) + { + data[datum.Key] = await datum.ReadValue(CancellationToken.None); + } + + return data; + } + + static string Reported(Dictionary data) => string.Join("|", data.Values); + + class StoredNotifications : INotificationsDataStore + { + public Task LoadSettings(CancellationToken cancellationToken = default) => + Task.FromResult(new NotificationsSettings()); + + public Task SaveSettings(NotificationsSettings settings, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + } + + static readonly string[] Variables = + [ + "SERVICECONTROL_VALIDATECONFIG", + "SERVICECONTROL_AUTHENTICATION_ENABLED", + "SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA", + "SERVICECONTROL_AUTHENTICATION_ROLESCLAIM", + "SERVICECONTROL_CORS_ALLOWEDORIGINS", + "SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", + "SERVICECONTROL_FORWARDEDHEADERS_ENABLED", + "SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS", + "SERVICECONTROL_HTTPS_ENABLEHSTS", + "SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE", + "MONITORING_URL", + "DEFAULT_ROUTE", + "SHOW_PENDING_RETRY", + "SERVICECONTROL_INGESTERRORMESSAGES", + "SERVICECONTROL_VIRTUALDIRECTORY" + ]; +} diff --git a/src/ServiceControl/ErrorInstanceStoredChoicesEnvironmentDataProvider.cs b/src/ServiceControl/ErrorInstanceStoredChoicesEnvironmentDataProvider.cs new file mode 100644 index 0000000000..0c1f7fbbb8 --- /dev/null +++ b/src/ServiceControl/ErrorInstanceStoredChoicesEnvironmentDataProvider.cs @@ -0,0 +1,57 @@ +namespace Particular.ServiceControl; + +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using global::ServiceControl.Persistence; +using global::ServiceControl.Persistence.MessageRedirects; +using Particular.LicensingComponent.Contracts; +using Particular.LicensingComponent.Persistence; +using ServiceBus.Management.Infrastructure.Settings; +using static Particular.LicensingComponent.Contracts.EnvironmentDatum; + +class ErrorInstanceStoredChoicesEnvironmentDataProvider( + Settings settings, + IEndpointSettingsStore endpointSettingsStore, + IEndpointInstanceMonitoring endpointInstanceMonitoring, + IMessageRedirectsDataStore messageRedirectsDataStore, + ILicensingDataStore licensingDataStore) : IEnvironmentDataProvider +{ + public IEnumerable GetData() + { + Task tracking = null; + Task Tracking(CancellationToken cancellationToken) => tracking ??= ReadInstanceTracking(cancellationToken); + + return + [ + Deferred("Heartbeats.TrackInstancesDefault", async cancellationToken => (await Tracking(cancellationToken)).TrackByDefault ? "Enabled" : "Disabled"), + Deferred("Heartbeats.TrackInstancesOverrides", async cancellationToken => Count((await Tracking(cancellationToken)).Overrides)), + Value("Heartbeats.KnownInstances", () => Count(endpointInstanceMonitoring.GetEndpoints().Length)), + Value("Heartbeats.MonitoredInstances", () => Count(endpointInstanceMonitoring.GetEndpoints().Count(endpoint => endpoint.Monitored))), + Deferred("Recoverability.Redirects", async cancellationToken => Count((await messageRedirectsDataStore.GetRedirects(cancellationToken)).Count)), + Deferred("Licensing.ReportMasks", async cancellationToken => Count((await licensingDataStore.GetReportMasks(cancellationToken)).Count)) + ]; + } + + async Task ReadInstanceTracking(CancellationToken cancellationToken) + { + var endpointSettings = new List(); + + await foreach (var endpoint in endpointSettingsStore.GetAllEndpointSettings(cancellationToken)) + { + endpointSettings.Add(endpoint); + } + + var defaultRow = endpointSettings.FirstOrDefault(endpoint => endpoint.Name == string.Empty); + var trackByDefault = defaultRow?.TrackInstances ?? settings.TrackInstancesInitialValue; + var overrides = endpointSettings.Count(endpoint => endpoint.Name != string.Empty && endpoint.TrackInstances != trackByDefault); + + return new InstanceTracking(trackByDefault, overrides); + } + + static string Count(int count) => count.ToString(CultureInfo.InvariantCulture); + + record InstanceTracking(bool TrackByDefault, int Overrides); +} diff --git a/src/ServiceControl/ErrorInstanceTuningEnvironmentDataProvider.cs b/src/ServiceControl/ErrorInstanceTuningEnvironmentDataProvider.cs new file mode 100644 index 0000000000..03136ad692 --- /dev/null +++ b/src/ServiceControl/ErrorInstanceTuningEnvironmentDataProvider.cs @@ -0,0 +1,36 @@ +namespace Particular.ServiceControl; + +using System; +using System.Collections.Generic; +using System.Globalization; +using global::ServiceControl.Configuration; +using Particular.LicensingComponent.Contracts; +using ServiceBus.Management.Infrastructure.Settings; +using static Particular.LicensingComponent.Contracts.EnvironmentDatum; + +class ErrorInstanceTuningEnvironmentDataProvider(Settings settings) : IEnvironmentDataProvider +{ + public IEnumerable GetData() => + [ + Value("Host.ShutdownTimeoutSeconds", () => WhenConfigured(Settings.ShutdownTimeoutKey, () => Seconds(settings.ShutdownTimeout))), + Value("Limits.ExternalIntegrationsBatchSize", () => WhenConfigured(Settings.ExternalIntegrationsDispatchingBatchSizeKey, () => Number(settings.ExternalIntegrationsDispatchingBatchSize))), + Value("Ingestion.Error.MaxConcurrency", () => WhenConfigured(Settings.MaximumConcurrencyLevelKey, () => Number(settings.MaximumConcurrencyLevel))), + Value("Ingestion.Error.BatchSize", () => WhenConfigured(nameof(Settings.ErrorIngestionBatchSize), () => Number(settings.ErrorIngestionBatchSize))), + Value("Ingestion.Error.MaxParallelWriters", () => WhenConfigured(nameof(Settings.ErrorIngestionMaxParallelWriters), () => Number(settings.ErrorIngestionMaxParallelWriters))), + Value("Ingestion.Error.BatchTimeoutMs", () => WhenConfigured(nameof(Settings.ErrorIngestionBatchTimeout), () => Milliseconds(settings.ErrorIngestionBatchTimeout))), + Value("Ingestion.Error.RestartAfterFailureSeconds", () => WhenConfigured(Settings.TimeToRestartErrorIngestionAfterFailureKey, () => Seconds(settings.TimeToRestartErrorIngestionAfterFailure))), + Value("Heartbeats.GracePeriodSeconds", () => WhenConfigured(Settings.HeartbeatGracePeriodKey, () => Seconds(settings.HeartbeatGracePeriod))), + Value("Recoverability.RetryHistoryDepth", () => WhenConfigured(Settings.RetryHistoryDepthKey, () => Number(settings.RetryHistoryDepth))) + ]; + + static string WhenConfigured(string key, Func readValue) => + SettingsReader.TryRead(Settings.SettingsRootNamespace, key, out _) ? readValue() : "Default"; + + static string Number(int? value) => value?.ToString(CultureInfo.InvariantCulture) ?? "Unknown"; + + static string Seconds(TimeSpan value) => + Math.Round(value.TotalSeconds, MidpointRounding.AwayFromZero).ToString("F0", CultureInfo.InvariantCulture); + + static string Milliseconds(TimeSpan value) => + Math.Round(value.TotalMilliseconds, MidpointRounding.AwayFromZero).ToString("F0", CultureInfo.InvariantCulture); +} diff --git a/src/ServiceControl/HostApplicationBuilderExtensions.cs b/src/ServiceControl/HostApplicationBuilderExtensions.cs index cc43874a16..6b3ac7c6e0 100644 --- a/src/ServiceControl/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl/HostApplicationBuilderExtensions.cs @@ -84,6 +84,9 @@ public static void AddServiceControl(this IHostApplicationBuilder hostBuilder, S services.AddSingleton(settings); services.AddEnvironmentDataProvider(); services.AddEnvironmentDataProvider(); + services.AddEnvironmentDataProvider(); + services.AddEnvironmentDataProvider(); + services.AddEnvironmentDataProvider(); services.AddHttpLogging(options => { diff --git a/src/ServiceControl/Infrastructure/Settings/Settings.cs b/src/ServiceControl/Infrastructure/Settings/Settings.cs index af52f4bf88..d4da55d4de 100644 --- a/src/ServiceControl/Infrastructure/Settings/Settings.cs +++ b/src/ServiceControl/Infrastructure/Settings/Settings.cs @@ -65,8 +65,8 @@ public Settings( } ProcessRetryBatchesFrequency = TimeSpan.FromSeconds(30); - MaximumConcurrencyLevel = SettingsReader.Read(SettingsRootNamespace, "MaximumConcurrencyLevel"); - RetryHistoryDepth = SettingsReader.Read(SettingsRootNamespace, "RetryHistoryDepth", 10); + MaximumConcurrencyLevel = SettingsReader.Read(SettingsRootNamespace, MaximumConcurrencyLevelKey); + RetryHistoryDepth = SettingsReader.Read(SettingsRootNamespace, RetryHistoryDepthKey, 10); AllowMessageEditing = SettingsReader.Read(SettingsRootNamespace, "AllowMessageEditing"); EnableIntegratedServicePulse = SettingsReader.Read(SettingsRootNamespace, "EnableIntegratedServicePulse", false); if (EnableIntegratedServicePulse) @@ -86,7 +86,7 @@ public Settings( ErrorIngestionBatchTimeout = IngestionSettingsReader.ReadBatchTimeout(SettingsRootNamespace, nameof(ErrorIngestionBatchTimeout), ValidateConfiguration); DisableExternalIntegrationsPublishing = SettingsReader.Read(SettingsRootNamespace, "DisableExternalIntegrationsPublishing", false); TrackInstancesInitialValue = SettingsReader.Read(SettingsRootNamespace, "TrackInstancesInitialValue", true); - ShutdownTimeout = SettingsReader.Read(SettingsRootNamespace, "ShutdownTimeout", ShutdownTimeout); + ShutdownTimeout = SettingsReader.Read(SettingsRootNamespace, ShutdownTimeoutKey, ShutdownTimeout); AssemblyLoadContextResolver = static assemblyPath => new PluginAssemblyLoadContext(assemblyPath); } @@ -130,7 +130,7 @@ public Settings( public bool ValidateConfiguration => SettingsReader.Read(SettingsRootNamespace, "ValidateConfig", true); - public int ExternalIntegrationsDispatchingBatchSize => SettingsReader.Read(SettingsRootNamespace, "ExternalIntegrationsDispatchingBatchSize", 100); + public int ExternalIntegrationsDispatchingBatchSize => SettingsReader.Read(SettingsRootNamespace, ExternalIntegrationsDispatchingBatchSizeKey, 100); public bool DisableExternalIntegrationsPublishing { get; set; } @@ -381,7 +381,7 @@ TimeSpan GetHeartbeatGracePeriod() { try { - return TimeSpan.Parse(SettingsReader.Read(SettingsRootNamespace, "HeartbeatGracePeriod", "00:00:40")); + return TimeSpan.Parse(SettingsReader.Read(SettingsRootNamespace, HeartbeatGracePeriodKey, "00:00:40")); } catch (Exception ex) { @@ -393,7 +393,7 @@ TimeSpan GetHeartbeatGracePeriod() TimeSpan GetTimeToRestartErrorIngestionAfterFailure() { string message; - var valueRead = SettingsReader.Read(SettingsRootNamespace, "TimeToRestartErrorIngestionAfterFailure"); + var valueRead = SettingsReader.Read(SettingsRootNamespace, TimeToRestartErrorIngestionAfterFailureKey); if (valueRead == null) { return TimeSpan.FromSeconds(60); @@ -483,6 +483,12 @@ void LoadErrorIngestionSettings() readonly ILogger logger = LoggerUtil.CreateStaticLogger(); public const string DEFAULT_INSTANCE_NAME = "Particular.ServiceControl"; + public const string MaximumConcurrencyLevelKey = "MaximumConcurrencyLevel"; + public const string RetryHistoryDepthKey = "RetryHistoryDepth"; + public const string ShutdownTimeoutKey = "ShutdownTimeout"; + public const string ExternalIntegrationsDispatchingBatchSizeKey = "ExternalIntegrationsDispatchingBatchSize"; + public const string HeartbeatGracePeriodKey = "HeartbeatGracePeriod"; + public const string TimeToRestartErrorIngestionAfterFailureKey = "TimeToRestartErrorIngestionAfterFailure"; public static readonly SettingsRootNamespace SettingsRootNamespace = new("ServiceControl"); } } \ No newline at end of file diff --git a/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs b/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs index 4e73a2a1f0..f1ea58156a 100644 --- a/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs +++ b/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs @@ -3,25 +3,43 @@ namespace Particular.ServiceControl; using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using global::ServiceControl.Infrastructure; using global::ServiceControl.Persistence; +using Microsoft.Extensions.Configuration; using Particular.LicensingComponent.Contracts; using ServiceBus.Management.Infrastructure.Settings; using static Particular.LicensingComponent.Contracts.EnvironmentDatum; -class ServiceControlErrorInstanceEnvironmentDataProvider(Settings settings, INotificationsDataStore notificationsDataStore) : IEnvironmentDataProvider +class ServiceControlErrorInstanceEnvironmentDataProvider(Settings settings, INotificationsDataStore notificationsDataStore, IConfiguration configuration) : IEnvironmentDataProvider { public IEnumerable GetData() => [ Value("Security.Authentication", () => Toggle(settings.OpenIdConnectSettings.Enabled)), Value("Security.RoleBasedAuthorization", () => Toggle(settings.OpenIdConnectSettings.RoleBasedAuthorizationEnabled)), + Value("Security.TokenValidation", TokenValidation), + Value("Security.ClaimMapping", ClaimMapping), + Value("Security.ServicePulseOfflineAccess", ServicePulseOfflineAccess), Value("Security.Https", () => Toggle(settings.HttpsSettings.Enabled)), + Value("Security.HttpsHardening", HttpsHardening), + Value("Security.Cors", () => settings.CorsSettings.AllowAnyOrigin ? "AnyOrigin" : "Restricted"), + Value("Security.ForwardedHeaders", ForwardedHeaders), Value("Features.IntegratedServicePulse", () => Toggle(settings.EnableIntegratedServicePulse)), Value("Features.MessageEditing", () => Toggle(settings.AllowMessageEditing)), Value("Features.ExternalIntegrationsPublishing", () => Toggle(!settings.DisableExternalIntegrationsPublishing)), Value("Features.ForwardErrorMessages", () => Toggle(settings.ForwardErrorMessages)), + Value("Features.ErrorIngestion", () => Toggle(settings.IngestErrorMessages)), + Value("Features.ConfigurationValidation", () => Toggle(settings.ValidateConfiguration)), Deferred("Features.EmailNotifications", EmailNotifications), + Value("ServicePulse.MonitoringUrl", ServicePulseMonitoringUrl), + Value("ServicePulse.DefaultRoute", () => WhenServicePulseIntegrated(() => EnvironmentVariableSet(ServicePulseDefaultRouteVariable) ? "Custom" : "Default")), + Value("ServicePulse.ShowPendingRetry", () => WhenServicePulseIntegrated(() => Toggle(settings.ServicePulseSettings.ShowPendingRetry))), + Value("Host.VirtualDirectory", () => string.IsNullOrEmpty(settings.VirtualDirectory) ? "None" : "Configured"), + Value("Logging.Providers", LoggingProviders), + Value("Logging.Level", () => settings.LoggingSettings.LogLevel.ToString()), + Value("Telemetry.OtlpMetrics", () => Toggle(OtlpEndpoint.Read(configuration) is not null)), Value("Retention.ErrorHours", () => Hours(settings.ErrorRetentionPeriod)), Value("Retention.EventsHours", () => Hours(settings.EventsRetentionPeriod)) ]; @@ -31,6 +49,63 @@ public IEnumerable GetData() => static string Hours(TimeSpan retentionPeriod) => Math.Round(retentionPeriod.TotalHours, MidpointRounding.AwayFromZero).ToString("F0", CultureInfo.InvariantCulture); + string TokenValidation() + { + var authentication = settings.OpenIdConnectSettings; + + if (!authentication.Enabled) + { + return NotApplicable; + } + + var allValidationOn = authentication.ValidateIssuer + && authentication.ValidateAudience + && authentication.ValidateLifetime + && authentication.ValidateIssuerSigningKey + && authentication.RequireHttpsMetadata; + + return allValidationOn ? "Default" : "Relaxed"; + } + + string ClaimMapping() + { + var authentication = settings.OpenIdConnectSettings; + + if (!authentication.Enabled) + { + return NotApplicable; + } + + var defaultClaims = authentication.RolesClaim == OpenIdConnectSettings.DefaultRolesClaim + && authentication.SubjectIdClaim == OpenIdConnectSettings.DefaultSubjectIdClaim + && authentication.SubjectNameClaim == OpenIdConnectSettings.DefaultSubjectNameClaim; + + return defaultClaims ? "Default" : "Custom"; + } + + string ServicePulseOfflineAccess() => + settings.OpenIdConnectSettings.Enabled ? Toggle(settings.OpenIdConnectSettings.ServicePulseOfflineAccessScopeEnabled) : NotApplicable; + + string HttpsHardening() => (settings.HttpsSettings.RedirectHttpToHttps, settings.HttpsSettings.EnableHsts) switch + { + (true, true) => "RedirectAndHsts", + (true, false) => "Redirect", + (false, true) => "Hsts", + _ => "None" + }; + + string ForwardedHeaders() + { + var forwardedHeaders = settings.ForwardedHeadersSettings; + + if (!forwardedHeaders.Enabled) + { + return "Disabled"; + } + + return forwardedHeaders.TrustAllProxies ? "TrustAllProxies" : "KnownProxies"; + } + async ValueTask EmailNotifications(CancellationToken cancellationToken) { var notificationsSettings = await notificationsDataStore.LoadSettings(cancellationToken); @@ -42,4 +117,29 @@ async ValueTask EmailNotifications(CancellationToken cancellationToken) return string.IsNullOrWhiteSpace(notificationsSettings.Email.SmtpServer) ? "NotConfigured" : "Disabled"; } + + string ServicePulseMonitoringUrl() => WhenServicePulseIntegrated(() => + { + if (settings.ServicePulseSettings.MonitoringUrl is null) + { + return "Disabled"; + } + + return EnvironmentVariableSet(ServicePulseMonitoringUrlVariable) || EnvironmentVariableSet(ServicePulseLegacyMonitoringUrlsVariable) ? "Custom" : "Default"; + }); + + string WhenServicePulseIntegrated(Func readValue) => settings.EnableIntegratedServicePulse ? readValue() : NotApplicable; + + static bool EnvironmentVariableSet(string name) => Environment.GetEnvironmentVariable(name) is not null; + + static string LoggingProviders() + { + var providers = new[] { Loggers.NLog, Loggers.Seq, Loggers.Otlp }.Where(LoggerUtil.IsLoggingTo).ToArray(); + return providers.Length == 0 ? "None" : string.Join(",", providers); + } + + const string NotApplicable = "NotApplicable"; + const string ServicePulseMonitoringUrlVariable = "MONITORING_URL"; + const string ServicePulseLegacyMonitoringUrlsVariable = "MONITORING_URLS"; + const string ServicePulseDefaultRouteVariable = "DEFAULT_ROUTE"; } diff --git a/src/ServiceControl/TransportEnvironmentDataProvider.cs b/src/ServiceControl/TransportEnvironmentDataProvider.cs new file mode 100644 index 0000000000..c2606ae6b0 --- /dev/null +++ b/src/ServiceControl/TransportEnvironmentDataProvider.cs @@ -0,0 +1,17 @@ +namespace Particular.ServiceControl; + +using System.Collections.Generic; +using System.Linq; +using global::ServiceControl.Transports; +using Particular.LicensingComponent.Contracts; +using ServiceBus.Management.Infrastructure.Settings; +using static Particular.LicensingComponent.Contracts.EnvironmentDatum; + +class TransportEnvironmentDataProvider(Settings settings, ITransportCustomization transportCustomization, TransportSettings transportSettings) : IEnvironmentDataProvider +{ + public IEnumerable GetData() => + [ + Value("Transport.Type", () => TransportManifestLibrary.Find(settings.TransportType)?.Name ?? "Unknown"), + .. transportCustomization.GetEnvironmentData(transportSettings).Select(datum => Value(datum.Key, datum.ReadValue)) + ]; +} From d8757efda147d3faef122e3e3fb5a3f24ec7d66f Mon Sep 17 00:00:00 2001 From: John Simons Date: Tue, 6 Oct 2026 12:23:09 +1000 Subject: [PATCH 2/2] Remove authentication and security posture telemetry from the usage report Drops the Transport.Auth, Transport.CertificateValidation, Storage.Auth, Storage.BodyStorage.Auth, and Security.* environment data keys (and their providers/tests) that reported authentication modes and security hardening posture. Simplifies TransportCustomization by replacing the GetAuthenticationMode/RelaxesCertificateValidation/GetEnvironmentDataCore template method with a single overridable GetEnvironmentData method. Also fixes SQL Server and PostgreSQL version reporting to return a reliable numeric major version instead of the raw version string, and adds coverage for the SqlVersion query data. --- .../When_reporting_the_environment.cs | 15 ----- .../OpenIdConnectSettings.cs | 10 +-- .../PostgreSqlPersistence.cs | 2 - ...geAuthenticationEnvironmentDataProvider.cs | 25 ------- .../SqlServerDatabaseHostingProbe.cs | 10 +-- .../SqlServerPersistence.cs | 2 - ...geAuthenticationEnvironmentDataProvider.cs | 25 ------- .../EFEnvironmentDataProvider.cs | 13 ---- .../RavenEnvironmentDataProvider.cs | 12 ---- ...orageAuthenticationEnvironmentDataTests.cs | 24 ------- .../EnvironmentDataConfigurationTests.cs | 17 +---- .../SqlServerDatabaseHostingProbeTests.cs | 20 ++++-- ...orageAuthenticationEnvironmentDataTests.cs | 25 ------- .../EFCore/EnvironmentDataTests.cs | 47 ++----------- .../EnvironmentDataTests.cs | 8 --- .../ASBSTransportCustomization.cs | 12 +--- .../EnvironmentDataTests.cs | 8 --- .../PostgreSqlQueryTests.cs | 1 + .../DatabaseDetails.cs | 7 +- .../PostgreSqlTransportCustomization.cs | 15 +---- ...nventionalRoutingTransportCustomization.cs | 8 +-- ...itMQDirectRoutingTransportCustomization.cs | 8 +-- .../RabbitMQTransportExtensions.cs | 11 +-- .../EnvironmentDataTests.cs | 6 +- .../EnvironmentDataTests.cs | 3 - .../SQSTransportCustomization.cs | 8 +-- .../EnvironmentDataTests.cs | 19 +++--- .../SqlServerQueryTests.cs | 1 + .../DatabaseDetails.cs | 21 +++++- .../SqlServerTransportCustomization.cs | 15 +---- ...ovals.ServiceControlTransport.approved.txt | 5 +- .../TransportCustomization.cs | 13 +--- ...rorInstanceEnvironmentDataProviderTests.cs | 67 +------------------ ...rolErrorInstanceEnvironmentDataProvider.cs | 66 ------------------ 34 files changed, 77 insertions(+), 472 deletions(-) delete mode 100644 src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlStorageAuthenticationEnvironmentDataProvider.cs delete mode 100644 src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerStorageAuthenticationEnvironmentDataProvider.cs delete mode 100644 src/ServiceControl.Persistence.Tests.PostgreSql/StorageAuthenticationEnvironmentDataTests.cs delete mode 100644 src/ServiceControl.Persistence.Tests.SqlServer/StorageAuthenticationEnvironmentDataTests.cs diff --git a/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs b/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs index d1acc58fc3..d5f23ea1e8 100644 --- a/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs +++ b/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs @@ -63,9 +63,7 @@ await Define() Assert.That(data["Host.Model"], Is.AnyOf("Container", "WindowsService", "Console")); Assert.That(data["Storage.Type"], Is.Not.Empty); Assert.That(data["Storage.BodyStorage.Type"], Is.Not.Empty); - Assert.That(data["Storage.BodyStorage.Auth"], Is.AnyOf("ManagedIdentity", "SharedKeyOrSas", "IamRole", "StaticCredentials", "NotApplicable")); Assert.That(data["Storage.HostingSource"], Is.AnyOf("Probe", "Configuration", "ConnectionString", "None")); - Assert.That(data["Security.Authentication"], Is.AnyOf("Enabled", "Disabled")); Assert.That(data["Features.EmailNotifications"], Is.AnyOf("Enabled", "Disabled", "NotConfigured", "ReadFailed")); Assert.That(int.Parse(data["Retention.ErrorHours"]), Is.GreaterThan(0)); @@ -90,22 +88,9 @@ await Define() "Storage.HostingSource", "Storage.FullTextSearch", "Storage.BodyStorage.Type", - "Storage.BodyStorage.Auth", - "Storage.Auth", "Storage.QueryTimeoutSeconds", "Storage.FreeSpaceThresholdPercent", "Transport.Type", - "Transport.Auth", - "Transport.CertificateValidation", - "Security.Authentication", - "Security.RoleBasedAuthorization", - "Security.TokenValidation", - "Security.ClaimMapping", - "Security.ServicePulseOfflineAccess", - "Security.Https", - "Security.HttpsHardening", - "Security.Cors", - "Security.ForwardedHeaders", "Features.IntegratedServicePulse", "Features.MessageEditing", "Features.ExternalIntegrationsPublishing", diff --git a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs index 2390311c01..81f6080836 100644 --- a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs +++ b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs @@ -11,10 +11,6 @@ namespace ServiceControl.Infrastructure; /// public class OpenIdConnectSettings { - public const string DefaultRolesClaim = "roles"; - public const string DefaultSubjectIdClaim = "sub"; - public const string DefaultSubjectNameClaim = "preferred_username"; - readonly ILogger logger = LoggerUtil.CreateStaticLogger(); /// @@ -37,15 +33,15 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC ValidateIssuerSigningKey = SettingsReader.Read(rootNamespace, "Authentication.ValidateIssuerSigningKey", true); RequireHttpsMetadata = SettingsReader.Read(rootNamespace, "Authentication.RequireHttpsMetadata", true); - RolesClaim = SettingsReader.Read(rootNamespace, "Authentication.RolesClaim", DefaultRolesClaim); + RolesClaim = SettingsReader.Read(rootNamespace, "Authentication.RolesClaim", "roles"); RoleBasedAuthorizationEnabled = SettingsReader.Read(rootNamespace, "Authentication.RoleBasedAuthorizationEnabled", false); // Claims that identify the principal in the authorization audit log. The handler treats both // as required — a missing or empty value is a sign that the IdP isn't emitting the expected // claim and the operator needs to fix the configuration, so the handler will throw rather // than substitute a placeholder. - SubjectIdClaim = SettingsReader.Read(rootNamespace, "Authentication.SubjectIdClaim", DefaultSubjectIdClaim); - SubjectNameClaim = SettingsReader.Read(rootNamespace, "Authentication.SubjectNameClaim", DefaultSubjectNameClaim); + SubjectIdClaim = SettingsReader.Read(rootNamespace, "Authentication.SubjectIdClaim", "sub"); + SubjectNameClaim = SettingsReader.Read(rootNamespace, "Authentication.SubjectNameClaim", "preferred_username"); // ServicePulse settings are only relevant for the primary ServiceControl instance // which serves the OIDC configuration endpoint that ServicePulse uses for login diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs index 4702ccbd49..3cbe6c1549 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs @@ -5,7 +5,6 @@ namespace ServiceControl.Persistence.EFCore.PostgreSql; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.DependencyInjection; -using Particular.LicensingComponent.Contracts; using ServiceControl.Persistence.EFCore.Abstractions; using ServiceControl.Persistence.EFCore.DbContexts; using ServiceControl.Persistence.EFCore.Infrastructure; @@ -18,7 +17,6 @@ public void AddPersistence(IServiceCollection services) ConfigureDbContext(services); RegisterDataStores(services, settings); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlStorageAuthenticationEnvironmentDataProvider.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlStorageAuthenticationEnvironmentDataProvider.cs deleted file mode 100644 index 1c8b849274..0000000000 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlStorageAuthenticationEnvironmentDataProvider.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace ServiceControl.Persistence.EFCore.PostgreSql; - -using Npgsql; -using Particular.LicensingComponent.Contracts; -using static Particular.LicensingComponent.Contracts.EnvironmentDatum; - -class PostgreSqlStorageAuthenticationEnvironmentDataProvider(PostgreSqlPersisterSettings settings) : IEnvironmentDataProvider -{ - public IEnumerable GetData() => - [ - Value("Storage.Auth", Authentication) - ]; - - string Authentication() - { - var builder = new NpgsqlConnectionStringBuilder(settings.ConnectionString); - - if (!string.IsNullOrEmpty(builder.SslCertificate)) - { - return "ClientCertificate"; - } - - return !string.IsNullOrEmpty(builder.Password) || !string.IsNullOrEmpty(builder.Passfile) ? "Password" : "None"; - } -} diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs index 7b686d72c7..5ca4450e2e 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs @@ -24,7 +24,7 @@ public async Task Probe(CancellationToken cancellationToken = d await using var command = dbContext.Database.GetDbConnection().CreateCommand(); // ProductVersion rather than ProductMajorVersion: the latter is documented as SQL Server // only and comes back null on Azure SQL Database, Managed Instance and Synapse. - command.CommandText = "SELECT SERVERPROPERTY('EngineEdition'), SERVERPROPERTY('ProductVersion')"; + command.CommandText = "SELECT SERVERPROPERTY('EngineEdition'), SERVERPROPERTY('ProductVersion'), DB_ID('rdsadmin')"; command.CommandTimeout = ProbeTimeoutSeconds; await dbContext.Database.OpenConnectionAsync(cancellationToken); @@ -38,7 +38,9 @@ public async Task Probe(CancellationToken cancellationToken = d var engineEdition = Convert.ToInt32(reader.GetValue(0), CultureInfo.InvariantCulture); - return new DatabaseHosting(HostingFor(engineEdition, ConfiguredHost), MajorVersion(reader), DatabaseHostingSource.Probe); + var rds = !reader.IsDBNull(2); + + return new DatabaseHosting(HostingFor(engineEdition, rds, ConfiguredHost), MajorVersion(reader), DatabaseHostingSource.Probe); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -59,12 +61,12 @@ public async Task Probe(CancellationToken cancellationToken = d /// Synapse and Fabric are not mapped: ServiceControl does not run on them, and an edition we do /// not recognise is not evidence of an ordinary SQL Server, so it falls to the host name. /// - internal static string HostingFor(int engineEdition, string? host) => engineEdition switch + internal static string HostingFor(int engineEdition, bool rds, string? host) => engineEdition switch { AzureSqlDatabase => "AzureSql", AzureSqlManagedInstance => "AzureSqlManagedInstance", AzureSqlEdge => "AzureSqlEdge", - PersonalOrDesktop or Standard or Enterprise or Express => ManagedOrSelfHosted(host), + PersonalOrDesktop or Standard or Enterprise or Express => rds ? "AwsRds" : ManagedOrSelfHosted(host), _ => DatabaseHostClassifier.Classify(host) }; diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerPersistence.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerPersistence.cs index 41bf2fcf45..dead62d3cc 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerPersistence.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerPersistence.cs @@ -5,7 +5,6 @@ namespace ServiceControl.Persistence.EFCore.SqlServer; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.DependencyInjection; -using Particular.LicensingComponent.Contracts; using ServiceControl.Persistence.EFCore.Abstractions; using ServiceControl.Persistence.EFCore.DbContexts; using ServiceControl.Persistence.EFCore.Infrastructure; @@ -18,7 +17,6 @@ public void AddPersistence(IServiceCollection services) ConfigureDbContext(services); RegisterDataStores(services, settings); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerStorageAuthenticationEnvironmentDataProvider.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerStorageAuthenticationEnvironmentDataProvider.cs deleted file mode 100644 index 4ee5a8c318..0000000000 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerStorageAuthenticationEnvironmentDataProvider.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace ServiceControl.Persistence.EFCore.SqlServer; - -using Microsoft.Data.SqlClient; -using Particular.LicensingComponent.Contracts; -using static Particular.LicensingComponent.Contracts.EnvironmentDatum; - -class SqlServerStorageAuthenticationEnvironmentDataProvider(SqlServerPersisterSettings settings) : IEnvironmentDataProvider -{ - public IEnumerable GetData() => - [ - Value("Storage.Auth", Authentication) - ]; - - string Authentication() - { - var builder = new SqlConnectionStringBuilder(settings.ConnectionString); - - if (builder.Authentication is SqlAuthenticationMethod.NotSpecified) - { - return builder.IntegratedSecurity ? "Integrated" : "SqlPassword"; - } - - return builder.Authentication is SqlAuthenticationMethod.SqlPassword ? "SqlPassword" : "EntraId"; - } -} diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs b/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs index dcf5e2a0da..f5ccfaf98a 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs @@ -25,7 +25,6 @@ public IEnumerable GetData() Deferred("Storage.HostingSource", async cancellationToken => (await Hosting(cancellationToken)).Source), Value("Storage.FullTextSearch", () => settings.EnableFullTextSearchOnBodies ? "Enabled" : "Disabled"), Value("Storage.BodyStorage.Type", () => BodyStorageType(settings.BodyStorage)), - Value("Storage.BodyStorage.Auth", () => BodyStorageAuth(settings.BodyStorage)), Value("Limits.MaxBodySizeToStore", () => settings.BodyStorage.MaxBodySizeToStore.ToString(CultureInfo.InvariantCulture)), Value("Storage.Schema", () => settings.Schema is null ? "Default" : "Custom"), Value("Storage.CommandTimeoutSeconds", () => WhenConfigured(EFPersistenceConfigurationBase.CommandTimeoutKey, () => Number(settings.CommandTimeout))), @@ -56,17 +55,5 @@ static string Seconds(TimeSpan value) => _ => "Unknown" }; - static string BodyStorageAuth(BodyStorageSettings bodyStorage) => bodyStorage switch - { - AzureBlobBodyStorageSettings azureBlob => azureBlob.Authentication switch - { - AzureBlobManagedIdentityAuthentication => "ManagedIdentity", - AzureBlobSharedKeyAuthentication => "SharedKeyOrSas", - _ => "Unknown" - }, - S3BodyStorageSettings s3 => s3.Credentials is null ? "IamRole" : "StaticCredentials", - _ => "NotApplicable" - }; - static readonly SettingsRootNamespace SettingsNamespace = new("ServiceControl"); } diff --git a/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs b/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs index 5dc4f6321b..61c709b6d7 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs @@ -22,8 +22,6 @@ public IEnumerable GetData() => Value("Storage.HostingSource", () => Hosting().Source), Value("Storage.FullTextSearch", () => settings.EnableFullTextSearchOnBodies ? "Enabled" : "Disabled"), Value("Storage.BodyStorage.Type", () => "RavenAttachments"), - Value("Storage.BodyStorage.Auth", () => "NotApplicable"), - Value("Storage.Auth", Authentication), Value("Storage.LogLevel", () => settings.LogsMode), Value("Storage.QueryTimeoutSeconds", () => WhenConfigured(QueryTimeLimit.SettingName, () => Number((int)Math.Round(settings.QueryTimeout.TotalSeconds, MidpointRounding.AwayFromZero)))), Value("Storage.FreeSpaceThresholdPercent", () => WhenConfigured(RavenPersistenceConfiguration.DataSpaceRemainingThresholdKey, () => Number(settings.DataSpaceRemainingThreshold))), @@ -52,16 +50,6 @@ async ValueTask ServerVersion(CancellationToken cancellationToken) return buildNumber.ProductVersion ?? DatabaseHostClassifier.Unknown; } - string Authentication() - { - if (settings.UseEmbeddedServer) - { - return "NotApplicable"; - } - - return string.IsNullOrWhiteSpace(settings.ClientCertificatePath) && string.IsNullOrWhiteSpace(settings.ClientCertificateBase64) ? "None" : "ClientCertificate"; - } - static string WhenConfigured(string key, Func readValue) => SettingsReader.TryRead(SettingsNamespace, key, out _) ? readValue() : "Default"; diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/StorageAuthenticationEnvironmentDataTests.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/StorageAuthenticationEnvironmentDataTests.cs deleted file mode 100644 index c16d00545e..0000000000 --- a/src/ServiceControl.Persistence.Tests.PostgreSql/StorageAuthenticationEnvironmentDataTests.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace ServiceControl.Persistence.Tests.PostgreSql; - -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using NUnit.Framework; -using ServiceControl.Persistence.EFCore.Abstractions; -using ServiceControl.Persistence.EFCore.PostgreSql; - -[TestFixture] -class StorageAuthenticationEnvironmentDataTests -{ - [TestCase("Host=localhost;Database=sc;Username=sc;Password=contoso-secret", "Password")] - [TestCase("Host=localhost;Database=sc;Username=sc;Passfile=/home/sc/.pgpass", "Password")] - [TestCase("Host=localhost;Database=sc;Username=sc;SSL Certificate=/certs/client.crt", "ClientCertificate")] - [TestCase("Host=localhost;Database=sc;Username=sc", "None")] - public async Task Should_report_the_authentication_mode(string connectionString, string expected) - { - var settings = new PostgreSqlPersisterSettings { ConnectionString = connectionString, BodyStorage = new FileSystemBodyStorageSettings { StoragePath = "/var/bodies" } }; - var datum = new PostgreSqlStorageAuthenticationEnvironmentDataProvider(settings).GetData().Single(); - - Assert.That(await datum.ReadValue(CancellationToken.None), Is.EqualTo(expected)); - } -} diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/EnvironmentDataConfigurationTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/EnvironmentDataConfigurationTests.cs index 06548accaf..cd7c23e4dc 100644 --- a/src/ServiceControl.Persistence.Tests.RavenDB/EnvironmentDataConfigurationTests.cs +++ b/src/ServiceControl.Persistence.Tests.RavenDB/EnvironmentDataConfigurationTests.cs @@ -21,13 +21,12 @@ public void TearDown() } [Test] - public async Task Should_report_an_embedded_server_with_its_defaults() + public async Task Should_report_defaults_when_nothing_is_configured() { var data = await GetData(new RavenPersisterSettings()); using (Assert.EnterMultipleScope()) { - Assert.That(data["Storage.Auth"], Is.EqualTo("NotApplicable")); Assert.That(data["Storage.LogLevel"], Is.EqualTo("Operations")); Assert.That(data["Storage.QueryTimeoutSeconds"], Is.EqualTo("Default")); Assert.That(data["Storage.FreeSpaceThresholdPercent"], Is.EqualTo("Default")); @@ -36,19 +35,6 @@ public async Task Should_report_an_embedded_server_with_its_defaults() } } - [TestCase(null, "None")] - [TestCase("/certs/contoso.pfx", "ClientCertificate")] - public async Task Should_report_how_an_external_server_authenticates_without_naming_it(string clientCertificatePath, string expected) - { - var data = await GetData(new RavenPersisterSettings { ConnectionString = "https://raven.contoso.local:8080", ClientCertificatePath = clientCertificatePath }); - - using (Assert.EnterMultipleScope()) - { - Assert.That(data["Storage.Auth"], Is.EqualTo(expected)); - Assert.That(string.Join("|", data.Values), Does.Not.Contain("contoso")); - } - } - [Test] public async Task Should_report_configured_tuning_values() { @@ -82,7 +68,6 @@ static async Task> GetData(RavenPersisterSettings set static readonly HashSet ConfigurationKeys = [ - "Storage.Auth", "Storage.LogLevel", "Storage.QueryTimeoutSeconds", "Storage.FreeSpaceThresholdPercent", diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerDatabaseHostingProbeTests.cs b/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerDatabaseHostingProbeTests.cs index cd2c5830b1..31d19aea3d 100644 --- a/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerDatabaseHostingProbeTests.cs +++ b/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerDatabaseHostingProbeTests.cs @@ -12,7 +12,7 @@ class SqlServerDatabaseHostingProbeTests [TestCase(8, "AzureSqlManagedInstance")] [TestCase(9, "AzureSqlEdge")] public void Should_take_the_azure_service_from_the_engine_edition(int engineEdition, string expected) => - Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, "anything.example.com"), Is.EqualTo(expected), + Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, false, "anything.example.com"), Is.EqualTo(expected), "The engine names its own service, so the host name must not get a say"); [TestCase(1)] @@ -20,14 +20,20 @@ public void Should_take_the_azure_service_from_the_engine_edition(int engineEdit [TestCase(3)] [TestCase(4)] public void Should_report_an_ordinary_edition_on_an_unrecognised_host_as_self_hosted(int engineEdition) => - Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, "db01.corp.example"), Is.EqualTo("SelfHosted"), + Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, false, "db01.corp.example"), Is.EqualTo("SelfHosted"), "The server answered and is not an Azure service, which is evidence rather than absence of it"); [TestCase(2)] [TestCase(3)] - public void Should_still_recognise_rds_running_an_ordinary_edition(int engineEdition) => - Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, "sc.abcdef.eu-west-1.rds.amazonaws.com"), Is.EqualTo("AwsRds"), - "RDS for SQL Server reports Standard or Enterprise, so the host name is the only thing that gives it away"); + [TestCase(4)] + public void Should_recognise_rds_by_its_rdsadmin_database_whatever_the_host_name(int engineEdition) => + Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, true, "db01.corp.example"), Is.EqualTo("AwsRds"), + "RDS for SQL Server reports an ordinary edition, and a customer's own DNS name must not make it look self-hosted"); + + [TestCase(2)] + [TestCase(3)] + public void Should_still_recognise_rds_by_its_host_name_when_rdsadmin_is_not_visible(int engineEdition) => + Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, false, "sc.abcdef.eu-west-1.rds.amazonaws.com"), Is.EqualTo("AwsRds")); // Synapse (6, 11) and Fabric (12) are deliberately unmapped, so they take this path. [TestCase(6)] @@ -35,13 +41,13 @@ public void Should_still_recognise_rds_running_an_ordinary_edition(int engineEdi [TestCase(12)] [TestCase(99)] public void Should_fall_back_to_the_host_for_an_unmapped_edition(int engineEdition) => - Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, "sc.database.windows.net"), Is.EqualTo("AzureSql")); + Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, false, "sc.database.windows.net"), Is.EqualTo("AzureSql")); [TestCase(6)] [TestCase(11)] [TestCase(12)] [TestCase(99)] public void Should_report_unknown_for_an_unmapped_edition_on_an_unrecognised_host(int engineEdition) => - Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, "db01.corp.example"), Is.EqualTo("Unknown"), + Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, false, "db01.corp.example"), Is.EqualTo("Unknown"), "An edition we do not map is not evidence of an ordinary SQL Server"); } diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/StorageAuthenticationEnvironmentDataTests.cs b/src/ServiceControl.Persistence.Tests.SqlServer/StorageAuthenticationEnvironmentDataTests.cs deleted file mode 100644 index 22a269438d..0000000000 --- a/src/ServiceControl.Persistence.Tests.SqlServer/StorageAuthenticationEnvironmentDataTests.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace ServiceControl.Persistence.Tests.SqlServer; - -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using NUnit.Framework; -using ServiceControl.Persistence.EFCore.Abstractions; -using ServiceControl.Persistence.EFCore.SqlServer; - -[TestFixture] -class StorageAuthenticationEnvironmentDataTests -{ - [TestCase("Server=.;Database=sc;Integrated Security=true", "Integrated")] - [TestCase("Server=.;Database=sc;User Id=sc;Password=contoso-secret", "SqlPassword")] - [TestCase("Server=tcp:contoso.database.windows.net;Database=sc;Authentication=Active Directory Managed Identity", "EntraId")] - [TestCase("Server=tcp:contoso.database.windows.net;Database=sc;Authentication=Active Directory Default", "EntraId")] - [TestCase("Server=tcp:contoso.database.windows.net;Database=sc;Authentication=Sql Password;User Id=sc;Password=contoso-secret", "SqlPassword")] - public async Task Should_report_the_authentication_mode(string connectionString, string expected) - { - var settings = new SqlServerPersisterSettings { ConnectionString = connectionString, BodyStorage = new FileSystemBodyStorageSettings { StoragePath = "/var/bodies" } }; - var datum = new SqlServerStorageAuthenticationEnvironmentDataProvider(settings).GetData().Single(); - - Assert.That(await datum.ReadValue(CancellationToken.None), Is.EqualTo(expected)); - } -} diff --git a/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs index 1f279e2469..08c8e3e5a0 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs @@ -45,65 +45,30 @@ public void Should_report_unknown_when_there_is_no_host(string host) => class EFEnvironmentDataProviderTests { [Test] - public async Task Should_report_managed_identity_for_azure_blob_service_uri() + public async Task Should_report_azure_blob_body_storage() { var data = await GetData(new AzureBlobBodyStorageSettings { Authentication = new AzureBlobManagedIdentityAuthentication { ServiceUri = new Uri("https://account.blob.core.windows.net") } }); - Assert.Multiple(() => - { - Assert.That(data["Storage.BodyStorage.Type"], Is.EqualTo("AzureBlob")); - Assert.That(data["Storage.BodyStorage.Auth"], Is.EqualTo("ManagedIdentity")); - }); + Assert.That(data["Storage.BodyStorage.Type"], Is.EqualTo("AzureBlob")); } [Test] - public async Task Should_report_shared_key_for_azure_blob_connection_string() - { - var data = await GetData(new AzureBlobBodyStorageSettings - { - Authentication = new AzureBlobSharedKeyAuthentication { ConnectionString = "UseDevelopmentStorage=true" } - }); - - Assert.That(data["Storage.BodyStorage.Auth"], Is.EqualTo("SharedKeyOrSas")); - } - - [Test] - public async Task Should_report_iam_role_when_s3_has_no_static_credentials() + public async Task Should_report_s3_body_storage() { var data = await GetData(new S3BodyStorageSettings { BucketName = "bodies" }); - Assert.Multiple(() => - { - Assert.That(data["Storage.BodyStorage.Type"], Is.EqualTo("S3")); - Assert.That(data["Storage.BodyStorage.Auth"], Is.EqualTo("IamRole")); - }); - } - - [Test] - public async Task Should_report_static_credentials_when_s3_has_an_access_key() - { - var data = await GetData(new S3BodyStorageSettings - { - BucketName = "bodies", - Credentials = new S3StaticCredentials { AccessKeyId = "key", SecretAccessKey = "secret" } - }); - - Assert.That(data["Storage.BodyStorage.Auth"], Is.EqualTo("StaticCredentials")); + Assert.That(data["Storage.BodyStorage.Type"], Is.EqualTo("S3")); } [Test] - public async Task Should_report_file_system_body_storage_as_not_applicable_for_auth() + public async Task Should_report_file_system_body_storage() { var data = await GetData(new FileSystemBodyStorageSettings { StoragePath = "/var/lib/servicecontrol" }); - Assert.Multiple(() => - { - Assert.That(data["Storage.BodyStorage.Type"], Is.EqualTo("FileSystem")); - Assert.That(data["Storage.BodyStorage.Auth"], Is.EqualTo("NotApplicable")); - }); + Assert.That(data["Storage.BodyStorage.Type"], Is.EqualTo("FileSystem")); } [Test] diff --git a/src/ServiceControl.Transports.ASBS.Tests/EnvironmentDataTests.cs b/src/ServiceControl.Transports.ASBS.Tests/EnvironmentDataTests.cs index 59733fb0cc..af225d310f 100644 --- a/src/ServiceControl.Transports.ASBS.Tests/EnvironmentDataTests.cs +++ b/src/ServiceControl.Transports.ASBS.Tests/EnvironmentDataTests.cs @@ -13,13 +13,6 @@ class EnvironmentDataTests [TearDown] public void TearDown() => Environment.SetEnvironmentVariable(TopologyVariable, null); - [TestCase("Endpoint=sb://contoso.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=c2VjcmV0", "SharedAccessKey")] - [TestCase("Endpoint=sb://contoso.servicebus.windows.net/;Authentication=Managed Identity", "ManagedIdentity")] - [TestCase("Endpoint=sb://contoso.servicebus.windows.net/;Authentication=Managed Identity;ClientId=11111111-2222-3333-4444-555555555555", "ManagedIdentity")] - [TestCase("contoso.servicebus.windows.net", "DefaultAzureCredential")] - public void Should_report_the_authentication_mode(string connectionString, string expected) => - Assert.That(Read(connectionString)["Transport.Auth"], Is.EqualTo(expected)); - [Test] public void Should_report_defaults_when_the_connection_string_selects_no_options() { @@ -27,7 +20,6 @@ public void Should_report_defaults_when_the_connection_string_selects_no_options using (Assert.EnterMultipleScope()) { - Assert.That(data["Transport.CertificateValidation"], Is.EqualTo("Default")); Assert.That(data["Transport.AzureServiceBus.Topology"], Is.EqualTo("TopicPerEvent")); Assert.That(data["Transport.AzureServiceBus.Partitioning"], Is.EqualTo("Disabled")); Assert.That(data["Transport.AzureServiceBus.WebSockets"], Is.EqualTo("Disabled")); diff --git a/src/ServiceControl.Transports.ASBS/ASBSTransportCustomization.cs b/src/ServiceControl.Transports.ASBS/ASBSTransportCustomization.cs index bfaee82c29..c7411779ce 100644 --- a/src/ServiceControl.Transports.ASBS/ASBSTransportCustomization.cs +++ b/src/ServiceControl.Transports.ASBS/ASBSTransportCustomization.cs @@ -5,7 +5,6 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; - using Azure.Identity; using Azure.Messaging.ServiceBus; using Azure.Messaging.ServiceBus.Administration; using BrokerThroughput; @@ -28,16 +27,7 @@ public class ASBSTransportCustomization : TransportCustomization transportDefinition.TransportTransactionMode = TransportTransactionMode.ReceiveOnly; - protected override string GetAuthenticationMode(TransportSettings transportSettings) => - ConnectionStringParser.Parse(transportSettings.ConnectionString).AuthenticationMethod switch - { - SharedAccessSignatureAuthentication => "SharedAccessKey", - TokenCredentialAuthentication { Credential: ManagedIdentityCredential } => "ManagedIdentity", - TokenCredentialAuthentication => "DefaultAzureCredential", - _ => "Unknown" - }; - - protected override IEnumerable GetEnvironmentDataCore(TransportSettings transportSettings) + public override IEnumerable GetEnvironmentData(TransportSettings transportSettings) { ConnectionSettings Parse() => ConnectionStringParser.Parse(transportSettings.ConnectionString); diff --git a/src/ServiceControl.Transports.PostgreSql.Tests/EnvironmentDataTests.cs b/src/ServiceControl.Transports.PostgreSql.Tests/EnvironmentDataTests.cs index 0de2dd72bf..cb6c369e97 100644 --- a/src/ServiceControl.Transports.PostgreSql.Tests/EnvironmentDataTests.cs +++ b/src/ServiceControl.Transports.PostgreSql.Tests/EnvironmentDataTests.cs @@ -9,13 +9,6 @@ namespace ServiceControl.Transport.Tests; [TestFixture] class EnvironmentDataTests { - [TestCase("Host=localhost;Username=nsb;Password=contoso-secret", "Password")] - [TestCase("Host=localhost;Username=nsb;Passfile=/home/nsb/.pgpass", "Password")] - [TestCase("Host=localhost;Username=nsb;SSL Certificate=/certs/client.crt", "ClientCertificate")] - [TestCase("Host=localhost;Username=nsb", "None")] - public void Should_report_the_authentication_mode(string connectionString, string expected) => - Assert.That(Read(connectionString)["Transport.Auth"], Is.EqualTo(expected)); - [Test] public void Should_report_default_schema_and_subscriptions_table() { @@ -25,7 +18,6 @@ public void Should_report_default_schema_and_subscriptions_table() { Assert.That(data["Transport.PostgreSQL.QueueSchema"], Is.EqualTo("Default")); Assert.That(data["Transport.PostgreSQL.SubscriptionsTable"], Is.EqualTo("Default")); - Assert.That(data["Transport.CertificateValidation"], Is.EqualTo("Default")); } } diff --git a/src/ServiceControl.Transports.PostgreSql.Tests/PostgreSqlQueryTests.cs b/src/ServiceControl.Transports.PostgreSql.Tests/PostgreSqlQueryTests.cs index cbe89f5b78..fbcb493344 100644 --- a/src/ServiceControl.Transports.PostgreSql.Tests/PostgreSqlQueryTests.cs +++ b/src/ServiceControl.Transports.PostgreSql.Tests/PostgreSqlQueryTests.cs @@ -93,6 +93,7 @@ public async Task RunScenario() IBrokerQueue queue = queueNames.Find(name => ((BrokerQueueTable)name).SanitizedName == transportSettings.EndpointName); Assert.That(queue, Is.Not.Null); + Assert.That(query.Data["SqlVersion"], Does.Match(@"^\d+$")); long total = 0L; diff --git a/src/ServiceControl.Transports.PostgreSql/DatabaseDetails.cs b/src/ServiceControl.Transports.PostgreSql/DatabaseDetails.cs index e90ed6959b..cffabb03cd 100644 --- a/src/ServiceControl.Transports.PostgreSql/DatabaseDetails.cs +++ b/src/ServiceControl.Transports.PostgreSql/DatabaseDetails.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Threading; using System.Threading.Tasks; using Npgsql; @@ -69,9 +70,11 @@ async Task GetPostgreSqlVersion(CancellationToken cancellationToken) { await using var conn = await OpenConnectionAsync(cancellationToken); await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT version()"; + cmd.CommandText = "SELECT current_setting('server_version_num')::int"; - return (string)await cmd.ExecuteScalarAsync(cancellationToken); + var versionNumber = (int)await cmd.ExecuteScalarAsync(cancellationToken); + + return (versionNumber / 10000).ToString(CultureInfo.InvariantCulture); } public async Task> GetTables(CancellationToken cancellationToken = default) diff --git a/src/ServiceControl.Transports.PostgreSql/PostgreSqlTransportCustomization.cs b/src/ServiceControl.Transports.PostgreSql/PostgreSqlTransportCustomization.cs index 75f16e3a04..063c32b3e4 100644 --- a/src/ServiceControl.Transports.PostgreSql/PostgreSqlTransportCustomization.cs +++ b/src/ServiceControl.Transports.PostgreSql/PostgreSqlTransportCustomization.cs @@ -6,7 +6,6 @@ using BrokerThroughput; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Npgsql; using NServiceBus; using NServiceBus.Transport.PostgreSql; using ServiceControl.Infrastructure; @@ -44,19 +43,7 @@ protected override void AddTransportForMonitoringCore(IServiceCollection service services.AddHostedService(provider => provider.GetRequiredService()); } - protected override string GetAuthenticationMode(TransportSettings transportSettings) - { - var builder = new NpgsqlConnectionStringBuilder(transportSettings.ConnectionString.RemoveCustomConnectionStringParts(out _, out _)); - - if (!string.IsNullOrEmpty(builder.SslCertificate)) - { - return "ClientCertificate"; - } - - return !string.IsNullOrEmpty(builder.Password) || !string.IsNullOrEmpty(builder.Passfile) ? "Password" : "None"; - } - - protected override IEnumerable GetEnvironmentDataCore(TransportSettings transportSettings) => + public override IEnumerable GetEnvironmentData(TransportSettings transportSettings) => [ new("Transport.PostgreSQL.QueueSchema", () => { diff --git a/src/ServiceControl.Transports.RabbitMQ/RabbitMQConventionalRoutingTransportCustomization.cs b/src/ServiceControl.Transports.RabbitMQ/RabbitMQConventionalRoutingTransportCustomization.cs index d63328d0e2..5b41d12582 100644 --- a/src/ServiceControl.Transports.RabbitMQ/RabbitMQConventionalRoutingTransportCustomization.cs +++ b/src/ServiceControl.Transports.RabbitMQ/RabbitMQConventionalRoutingTransportCustomization.cs @@ -33,13 +33,7 @@ ManagementClient Get() } } - protected override string GetAuthenticationMode(TransportSettings transportSettings) => - RabbitMQTransportExtensions.GetAuthenticationMode(transportSettings.ConnectionString); - - protected override bool RelaxesCertificateValidation(TransportSettings transportSettings) => - RabbitMQTransportExtensions.RelaxesCertificateValidation(transportSettings.ConnectionString); - - protected override IEnumerable GetEnvironmentDataCore(TransportSettings transportSettings) => + public override IEnumerable GetEnvironmentData(TransportSettings transportSettings) => RabbitMQTransportExtensions.GetEnvironmentData(transportSettings.ConnectionString); protected override void CustomizeTransportForPrimaryEndpoint(EndpointConfiguration endpointConfiguration, RabbitMQTransport transportDefinition, TransportSettings transportSettings) => transport = transportDefinition; diff --git a/src/ServiceControl.Transports.RabbitMQ/RabbitMQDirectRoutingTransportCustomization.cs b/src/ServiceControl.Transports.RabbitMQ/RabbitMQDirectRoutingTransportCustomization.cs index df806869fc..82b710791a 100644 --- a/src/ServiceControl.Transports.RabbitMQ/RabbitMQDirectRoutingTransportCustomization.cs +++ b/src/ServiceControl.Transports.RabbitMQ/RabbitMQDirectRoutingTransportCustomization.cs @@ -33,13 +33,7 @@ ManagementClient Get() } } - protected override string GetAuthenticationMode(TransportSettings transportSettings) => - RabbitMQTransportExtensions.GetAuthenticationMode(transportSettings.ConnectionString); - - protected override bool RelaxesCertificateValidation(TransportSettings transportSettings) => - RabbitMQTransportExtensions.RelaxesCertificateValidation(transportSettings.ConnectionString); - - protected override IEnumerable GetEnvironmentDataCore(TransportSettings transportSettings) => + public override IEnumerable GetEnvironmentData(TransportSettings transportSettings) => RabbitMQTransportExtensions.GetEnvironmentData(transportSettings.ConnectionString); protected override void CustomizeTransportForPrimaryEndpoint(EndpointConfiguration endpointConfiguration, RabbitMQTransport transportDefinition, TransportSettings transportSettings) => transport = transportDefinition; diff --git a/src/ServiceControl.Transports.RabbitMQ/RabbitMQTransportExtensions.cs b/src/ServiceControl.Transports.RabbitMQ/RabbitMQTransportExtensions.cs index 3398dbcc26..71b7d5c811 100644 --- a/src/ServiceControl.Transports.RabbitMQ/RabbitMQTransportExtensions.cs +++ b/src/ServiceControl.Transports.RabbitMQ/RabbitMQTransportExtensions.cs @@ -31,7 +31,7 @@ public static void ApplySettingsFromConnectionString(this RabbitMQTransport tran transport.ValidateRemoteCertificate = !disableRemoteCertificateValidation; } - if (UsesExternalAuthMechanism(dictionary)) + if (dictionary.TryGetValue("UseExternalAuthMechanism", out var useExternalAuthMechanismString) && bool.TryParse(useExternalAuthMechanismString, out var useExternalAuthMechanism) && useExternalAuthMechanism) { transport.AuthMechanisms = [new ExternalMechanismFactory()]; } @@ -48,15 +48,6 @@ public static IEnumerable GetEnvironmentData(string c new("Transport.RabbitMQ.ManagementApi", () => ReadConnectionStringOptions(connectionString).ContainsKey("ManagementApiUrl") ? "Configured" : "Default") ]; - public static string GetAuthenticationMode(string connectionString) => - UsesExternalAuthMechanism(ReadConnectionStringOptions(connectionString)) ? "ExternalCertificate" : "Password"; - - public static bool RelaxesCertificateValidation(string connectionString) => - ReadConnectionStringOptions(connectionString).TryGetValue("DisableRemoteCertificateValidation", out var value) && bool.TryParse(value, out var disabled) && disabled; - - static bool UsesExternalAuthMechanism(Dictionary options) => - options.TryGetValue("UseExternalAuthMechanism", out var value) && bool.TryParse(value, out var useExternalAuthMechanism) && useExternalAuthMechanism; - static Dictionary ReadConnectionStringOptions(string connectionString) { if (connectionString.StartsWith("amqp", StringComparison.OrdinalIgnoreCase)) diff --git a/src/ServiceControl.Transports.RabbitMQClassicConventionalRouting.Tests/EnvironmentDataTests.cs b/src/ServiceControl.Transports.RabbitMQClassicConventionalRouting.Tests/EnvironmentDataTests.cs index f97f4cf58b..89c7f96045 100644 --- a/src/ServiceControl.Transports.RabbitMQClassicConventionalRouting.Tests/EnvironmentDataTests.cs +++ b/src/ServiceControl.Transports.RabbitMQClassicConventionalRouting.Tests/EnvironmentDataTests.cs @@ -17,8 +17,6 @@ public void Should_report_defaults_when_the_connection_string_selects_no_options using (Assert.EnterMultipleScope()) { - Assert.That(data["Transport.Auth"], Is.EqualTo("Password")); - Assert.That(data["Transport.CertificateValidation"], Is.EqualTo("Default")); Assert.That(data["Transport.RabbitMQ.DeliveryLimitValidation"], Is.EqualTo("Enabled")); Assert.That(data["Transport.RabbitMQ.ManagementApi"], Is.EqualTo("Default")); } @@ -27,7 +25,7 @@ public void Should_report_defaults_when_the_connection_string_selects_no_options [Test] public void Should_report_selected_options_on_either_routing_topology_without_their_values() { - const string connectionString = "host=rabbit.contoso.local;UseExternalAuthMechanism=true;DisableRemoteCertificateValidation=true;ValidateDeliveryLimits=false;ManagementApiUrl=https://rabbit.contoso.local:15671"; + const string connectionString = "host=rabbit.contoso.local;ValidateDeliveryLimits=false;ManagementApiUrl=https://rabbit.contoso.local:15671"; foreach (var customization in new ITransportCustomization[] { new RabbitMQClassicConventionalRoutingTransportCustomization(), new RabbitMQQuorumDirectRoutingTransportCustomization() }) { @@ -35,8 +33,6 @@ public void Should_report_selected_options_on_either_routing_topology_without_th using (Assert.EnterMultipleScope()) { - Assert.That(data["Transport.Auth"], Is.EqualTo("ExternalCertificate")); - Assert.That(data["Transport.CertificateValidation"], Is.EqualTo("Relaxed")); Assert.That(data["Transport.RabbitMQ.DeliveryLimitValidation"], Is.EqualTo("Disabled")); Assert.That(data["Transport.RabbitMQ.ManagementApi"], Is.EqualTo("Configured")); Assert.That(string.Join("|", data.Values), Does.Not.Contain("contoso").IgnoreCase); diff --git a/src/ServiceControl.Transports.SQS.Tests/EnvironmentDataTests.cs b/src/ServiceControl.Transports.SQS.Tests/EnvironmentDataTests.cs index 75f1c91e18..5ec8b228a9 100644 --- a/src/ServiceControl.Transports.SQS.Tests/EnvironmentDataTests.cs +++ b/src/ServiceControl.Transports.SQS.Tests/EnvironmentDataTests.cs @@ -16,8 +16,6 @@ public void Should_report_defaults_when_the_connection_string_carries_only_a_reg using (Assert.EnterMultipleScope()) { - Assert.That(data["Transport.Auth"], Is.EqualTo("IamRole")); - Assert.That(data["Transport.CertificateValidation"], Is.EqualTo("Default")); Assert.That(data["Transport.AmazonSQS.NamePrefixes"], Is.EqualTo("None")); Assert.That(data["Transport.AmazonSQS.LargeMessageBucket"], Is.EqualTo("None")); Assert.That(data["Transport.AmazonSQS.MessageWrapping"], Is.EqualTo("Enabled")); @@ -32,7 +30,6 @@ public void Should_report_selected_options_without_their_values() using (Assert.EnterMultipleScope()) { - Assert.That(data["Transport.Auth"], Is.EqualTo("StaticCredentials")); Assert.That(data["Transport.AmazonSQS.NamePrefixes"], Is.EqualTo("Queue")); Assert.That(data["Transport.AmazonSQS.LargeMessageBucket"], Is.EqualTo("Configured")); Assert.That(data["Transport.AmazonSQS.MessageWrapping"], Is.EqualTo("Disabled")); diff --git a/src/ServiceControl.Transports.SQS/SQSTransportCustomization.cs b/src/ServiceControl.Transports.SQS/SQSTransportCustomization.cs index aa43044c76..4252e7e0ff 100644 --- a/src/ServiceControl.Transports.SQS/SQSTransportCustomization.cs +++ b/src/ServiceControl.Transports.SQS/SQSTransportCustomization.cs @@ -44,13 +44,7 @@ protected override void AddTransportForMonitoringCore(IServiceCollection service services.AddHostedService(provider => provider.GetRequiredService()); } - protected override string GetAuthenticationMode(TransportSettings transportSettings) - { - var connectionString = new SQSTransportConnectionString(transportSettings.ConnectionString); - return connectionString.AccessKey != null || connectionString.SecretKey != null ? "StaticCredentials" : "IamRole"; - } - - protected override IEnumerable GetEnvironmentDataCore(TransportSettings transportSettings) + public override IEnumerable GetEnvironmentData(TransportSettings transportSettings) { SQSTransportConnectionString Parse() => new(transportSettings.ConnectionString); diff --git a/src/ServiceControl.Transports.SqlServer.Tests/EnvironmentDataTests.cs b/src/ServiceControl.Transports.SqlServer.Tests/EnvironmentDataTests.cs index 37bf1740e5..19dca3576b 100644 --- a/src/ServiceControl.Transports.SqlServer.Tests/EnvironmentDataTests.cs +++ b/src/ServiceControl.Transports.SqlServer.Tests/EnvironmentDataTests.cs @@ -9,13 +9,6 @@ namespace ServiceControl.Transport.Tests; [TestFixture] class EnvironmentDataTests { - [TestCase("Server=.;Database=nsb;Integrated Security=true", "Integrated")] - [TestCase("Server=.;Database=nsb;User Id=sa;Password=contoso-secret", "SqlPassword")] - [TestCase("Server=tcp:contoso.database.windows.net;Database=nsb;Authentication=Active Directory Managed Identity", "EntraId")] - [TestCase("Server=tcp:contoso.database.windows.net;Database=nsb;Authentication=Active Directory Default", "EntraId")] - public void Should_report_the_authentication_mode(string connectionString, string expected) => - Assert.That(Read(connectionString)["Transport.Auth"], Is.EqualTo(expected)); - [Test] public void Should_report_default_schema_and_subscriptions_table() { @@ -25,7 +18,6 @@ public void Should_report_default_schema_and_subscriptions_table() { Assert.That(data["Transport.SQLServer.QueueSchema"], Is.EqualTo("Default")); Assert.That(data["Transport.SQLServer.SubscriptionsTable"], Is.EqualTo("Default")); - Assert.That(data["Transport.CertificateValidation"], Is.EqualTo("Default")); } } @@ -36,13 +28,22 @@ public void Should_report_a_custom_schema_and_subscriptions_table_without_naming using (Assert.EnterMultipleScope()) { - Assert.That(data["Transport.Auth"], Is.EqualTo("SqlPassword")); Assert.That(data["Transport.SQLServer.QueueSchema"], Is.EqualTo("Custom")); Assert.That(data["Transport.SQLServer.SubscriptionsTable"], Is.EqualTo("Custom")); Assert.That(string.Join("|", data.Values), Does.Not.Contain("contoso").IgnoreCase); } } + [TestCase(3, "16.0.4225.2")] + [TestCase(8, "16.0.4165.4")] + public void Should_report_only_the_major_sql_version(int engineEdition, string productVersion) => + Assert.That(DatabaseDetails.VersionFor(engineEdition, productVersion), Is.EqualTo("16")); + + [Test] + public void Should_report_azure_sql_database_by_name_rather_than_by_version() => + Assert.That(DatabaseDetails.VersionFor(5, "12.0.2000.8"), Is.EqualTo("AzureSql"), + "Azure SQL Database always reports 12, which would read as SQL Server 2014"); + static Dictionary Read(string connectionString) => new SqlServerTransportCustomization() .GetEnvironmentData(new TransportSettings { ConnectionString = connectionString }) diff --git a/src/ServiceControl.Transports.SqlServer.Tests/SqlServerQueryTests.cs b/src/ServiceControl.Transports.SqlServer.Tests/SqlServerQueryTests.cs index 5784435401..0f9cdfed49 100644 --- a/src/ServiceControl.Transports.SqlServer.Tests/SqlServerQueryTests.cs +++ b/src/ServiceControl.Transports.SqlServer.Tests/SqlServerQueryTests.cs @@ -113,6 +113,7 @@ public async Task RunScenario() IBrokerQueue queue = queueNames.Find(name => ((BrokerQueueTable)name).Name == transportSettings.EndpointName); Assert.That(queue, Is.Not.Null); + Assert.That(query.Data["SqlVersion"], Does.Match(@"^(\d+|AzureSql)$")); long total = 0L; diff --git a/src/ServiceControl.Transports.SqlServer/DatabaseDetails.cs b/src/ServiceControl.Transports.SqlServer/DatabaseDetails.cs index 0ff57251e7..f8b072ce0a 100644 --- a/src/ServiceControl.Transports.SqlServer/DatabaseDetails.cs +++ b/src/ServiceControl.Transports.SqlServer/DatabaseDetails.cs @@ -2,6 +2,7 @@ { using System; using System.Collections.Generic; + using System.Globalization; using System.Threading; using System.Threading.Tasks; using Microsoft.Data.SqlClient; @@ -90,11 +91,27 @@ async Task GetSqlVersion(CancellationToken cancellationToken) { await using var conn = await OpenConnectionAsync(cancellationToken); await using var cmd = conn.CreateCommand(); - cmd.CommandText = "select @@VERSION"; + cmd.CommandText = "SELECT SERVERPROPERTY('EngineEdition'), SERVERPROPERTY('ProductVersion')"; - return (string)await cmd.ExecuteScalarAsync(cancellationToken); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + + return await reader.ReadAsync(cancellationToken) ? VersionFor(reader.GetValue(0), reader.GetValue(1)) : "Unknown"; + } + + internal static string VersionFor(object engineEdition, object productVersion) + { + if (engineEdition is AzureSqlDatabaseEngineEdition) + { + return "AzureSql"; + } + + var major = Convert.ToString(productVersion, CultureInfo.InvariantCulture)?.Split('.')[0]; + + return string.IsNullOrWhiteSpace(major) ? "Unknown" : major; } + const int AzureSqlDatabaseEngineEdition = 5; + public async Task> GetTables(CancellationToken cancellationToken = default) { List tables = []; diff --git a/src/ServiceControl.Transports.SqlServer/SqlServerTransportCustomization.cs b/src/ServiceControl.Transports.SqlServer/SqlServerTransportCustomization.cs index c018a667f7..35be6b3a11 100644 --- a/src/ServiceControl.Transports.SqlServer/SqlServerTransportCustomization.cs +++ b/src/ServiceControl.Transports.SqlServer/SqlServerTransportCustomization.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Runtime.CompilerServices; using BrokerThroughput; - using Microsoft.Data.SqlClient; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NServiceBus; @@ -52,19 +51,7 @@ protected override void AddTransportForMonitoringCore(IServiceCollection service services.AddHostedService(provider => provider.GetRequiredService()); } - protected override string GetAuthenticationMode(TransportSettings transportSettings) - { - var builder = new SqlConnectionStringBuilder(transportSettings.ConnectionString.RemoveCustomConnectionStringParts(out _, out _)); - - if (builder.Authentication is SqlAuthenticationMethod.NotSpecified) - { - return builder.IntegratedSecurity ? "Integrated" : "SqlPassword"; - } - - return builder.Authentication is SqlAuthenticationMethod.SqlPassword ? "SqlPassword" : "EntraId"; - } - - protected override IEnumerable GetEnvironmentDataCore(TransportSettings transportSettings) => + public override IEnumerable GetEnvironmentData(TransportSettings transportSettings) => [ new("Transport.SQLServer.QueueSchema", () => { diff --git a/src/ServiceControl.Transports.Tests/ApprovalFiles/APIApprovals.ServiceControlTransport.approved.txt b/src/ServiceControl.Transports.Tests/ApprovalFiles/APIApprovals.ServiceControlTransport.approved.txt index 00e84fc51b..df5a7bc609 100644 --- a/src/ServiceControl.Transports.Tests/ApprovalFiles/APIApprovals.ServiceControlTransport.approved.txt +++ b/src/ServiceControl.Transports.Tests/ApprovalFiles/APIApprovals.ServiceControlTransport.approved.txt @@ -60,11 +60,8 @@ namespace ServiceControl.Transports protected abstract void CustomizeTransportForAuditEndpoint(NServiceBus.EndpointConfiguration endpointConfiguration, TTransport transportDefinition, ServiceControl.Transports.TransportSettings transportSettings); protected abstract void CustomizeTransportForMonitoringEndpoint(NServiceBus.EndpointConfiguration endpointConfiguration, TTransport transportDefinition, ServiceControl.Transports.TransportSettings transportSettings); protected abstract void CustomizeTransportForPrimaryEndpoint(NServiceBus.EndpointConfiguration endpointConfiguration, TTransport transportDefinition, ServiceControl.Transports.TransportSettings transportSettings); - protected virtual string GetAuthenticationMode(ServiceControl.Transports.TransportSettings transportSettings) { } - public System.Collections.Generic.IEnumerable GetEnvironmentData(ServiceControl.Transports.TransportSettings transportSettings) { } - protected virtual System.Collections.Generic.IEnumerable GetEnvironmentDataCore(ServiceControl.Transports.TransportSettings transportSettings) { } + public virtual System.Collections.Generic.IEnumerable GetEnvironmentData(ServiceControl.Transports.TransportSettings transportSettings) { } public virtual System.Threading.Tasks.Task ProvisionQueues(ServiceControl.Transports.TransportSettings transportSettings, System.Collections.Generic.IEnumerable additionalQueues, System.Threading.CancellationToken cancellationToken = default) { } - protected virtual bool RelaxesCertificateValidation(ServiceControl.Transports.TransportSettings transportSettings) { } public string ToTransportQualifiedQueueName(string queueName) { } protected virtual string ToTransportQualifiedQueueNameCore(string queueName) { } } diff --git a/src/ServiceControl.Transports/TransportCustomization.cs b/src/ServiceControl.Transports/TransportCustomization.cs index 0294d438a6..f7d4142eba 100644 --- a/src/ServiceControl.Transports/TransportCustomization.cs +++ b/src/ServiceControl.Transports/TransportCustomization.cs @@ -50,18 +50,7 @@ protected virtual void AddTransportForPrimaryCore(IServiceCollection services, T { } - public IEnumerable GetEnvironmentData(TransportSettings transportSettings) => - [ - new("Transport.Auth", () => GetAuthenticationMode(transportSettings)), - new("Transport.CertificateValidation", () => RelaxesCertificateValidation(transportSettings) ? "Relaxed" : "Default"), - .. GetEnvironmentDataCore(transportSettings) - ]; - - protected virtual string GetAuthenticationMode(TransportSettings transportSettings) => "NotApplicable"; - - protected virtual bool RelaxesCertificateValidation(TransportSettings transportSettings) => false; - - protected virtual IEnumerable GetEnvironmentDataCore(TransportSettings transportSettings) => []; + public virtual IEnumerable GetEnvironmentData(TransportSettings transportSettings) => []; public void AddTransportForAudit(IServiceCollection services, TransportSettings transportSettings) { diff --git a/src/ServiceControl.UnitTests/Licensing/ServiceControlErrorInstanceEnvironmentDataProviderTests.cs b/src/ServiceControl.UnitTests/Licensing/ServiceControlErrorInstanceEnvironmentDataProviderTests.cs index bdcb266565..dd52a9fd60 100644 --- a/src/ServiceControl.UnitTests/Licensing/ServiceControlErrorInstanceEnvironmentDataProviderTests.cs +++ b/src/ServiceControl.UnitTests/Licensing/ServiceControlErrorInstanceEnvironmentDataProviderTests.cs @@ -25,68 +25,13 @@ public void TearDown() } [Test] - public async Task Should_report_default_security_posture_when_nothing_is_configured() - { - var data = await GetData(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(data["Security.TokenValidation"], Is.EqualTo("NotApplicable")); - Assert.That(data["Security.ClaimMapping"], Is.EqualTo("NotApplicable")); - Assert.That(data["Security.ServicePulseOfflineAccess"], Is.EqualTo("NotApplicable")); - Assert.That(data["Security.HttpsHardening"], Is.EqualTo("None")); - Assert.That(data["Security.Cors"], Is.EqualTo("AnyOrigin")); - Assert.That(data["Security.ForwardedHeaders"], Is.EqualTo("TrustAllProxies")); - } - } - - [Test] - public async Task Should_report_relaxed_token_validation_and_custom_claims_without_naming_them() + public async Task Should_report_configuration_validation_disabled() { Environment.SetEnvironmentVariable("SERVICECONTROL_VALIDATECONFIG", "false"); - Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ENABLED", "true"); - Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA", "false"); - Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ROLESCLAIM", "contoso_roles"); - - var data = await GetData(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(data["Security.TokenValidation"], Is.EqualTo("Relaxed")); - Assert.That(data["Security.ClaimMapping"], Is.EqualTo("Custom")); - Assert.That(data["Security.ServicePulseOfflineAccess"], Is.EqualTo("Enabled")); - Assert.That(data["Features.ConfigurationValidation"], Is.EqualTo("Disabled")); - Assert.That(Reported(data), Does.Not.Contain("contoso").IgnoreCase); - } - } - - [Test] - public async Task Should_report_restricted_web_access_without_naming_origins_or_proxies() - { - Environment.SetEnvironmentVariable("SERVICECONTROL_CORS_ALLOWEDORIGINS", "https://pulse.contoso.com"); - Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "10.0.0.7"); - Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS", "true"); - Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_ENABLEHSTS", "true"); - - var data = await GetData(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(data["Security.Cors"], Is.EqualTo("Restricted")); - Assert.That(data["Security.ForwardedHeaders"], Is.EqualTo("KnownProxies")); - Assert.That(data["Security.HttpsHardening"], Is.EqualTo("RedirectAndHsts")); - Assert.That(Reported(data), Does.Not.Contain("contoso").IgnoreCase.And.Not.Contain("10.0.0.7")); - } - } - - [Test] - public async Task Should_report_forwarded_headers_disabled() - { - Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_ENABLED", "false"); var data = await GetData(); - Assert.That(data["Security.ForwardedHeaders"], Is.EqualTo("Disabled")); + Assert.That(data["Features.ConfigurationValidation"], Is.EqualTo("Disabled")); } [Test] @@ -208,14 +153,6 @@ public Task SaveSettings(NotificationsSettings settings, CancellationToken cance static readonly string[] Variables = [ "SERVICECONTROL_VALIDATECONFIG", - "SERVICECONTROL_AUTHENTICATION_ENABLED", - "SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA", - "SERVICECONTROL_AUTHENTICATION_ROLESCLAIM", - "SERVICECONTROL_CORS_ALLOWEDORIGINS", - "SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", - "SERVICECONTROL_FORWARDEDHEADERS_ENABLED", - "SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS", - "SERVICECONTROL_HTTPS_ENABLEHSTS", "SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE", "MONITORING_URL", "DEFAULT_ROUTE", diff --git a/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs b/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs index f1ea58156a..6fa63c0606 100644 --- a/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs +++ b/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs @@ -17,15 +17,6 @@ class ServiceControlErrorInstanceEnvironmentDataProvider(Settings settings, INot { public IEnumerable GetData() => [ - Value("Security.Authentication", () => Toggle(settings.OpenIdConnectSettings.Enabled)), - Value("Security.RoleBasedAuthorization", () => Toggle(settings.OpenIdConnectSettings.RoleBasedAuthorizationEnabled)), - Value("Security.TokenValidation", TokenValidation), - Value("Security.ClaimMapping", ClaimMapping), - Value("Security.ServicePulseOfflineAccess", ServicePulseOfflineAccess), - Value("Security.Https", () => Toggle(settings.HttpsSettings.Enabled)), - Value("Security.HttpsHardening", HttpsHardening), - Value("Security.Cors", () => settings.CorsSettings.AllowAnyOrigin ? "AnyOrigin" : "Restricted"), - Value("Security.ForwardedHeaders", ForwardedHeaders), Value("Features.IntegratedServicePulse", () => Toggle(settings.EnableIntegratedServicePulse)), Value("Features.MessageEditing", () => Toggle(settings.AllowMessageEditing)), Value("Features.ExternalIntegrationsPublishing", () => Toggle(!settings.DisableExternalIntegrationsPublishing)), @@ -49,63 +40,6 @@ public IEnumerable GetData() => static string Hours(TimeSpan retentionPeriod) => Math.Round(retentionPeriod.TotalHours, MidpointRounding.AwayFromZero).ToString("F0", CultureInfo.InvariantCulture); - string TokenValidation() - { - var authentication = settings.OpenIdConnectSettings; - - if (!authentication.Enabled) - { - return NotApplicable; - } - - var allValidationOn = authentication.ValidateIssuer - && authentication.ValidateAudience - && authentication.ValidateLifetime - && authentication.ValidateIssuerSigningKey - && authentication.RequireHttpsMetadata; - - return allValidationOn ? "Default" : "Relaxed"; - } - - string ClaimMapping() - { - var authentication = settings.OpenIdConnectSettings; - - if (!authentication.Enabled) - { - return NotApplicable; - } - - var defaultClaims = authentication.RolesClaim == OpenIdConnectSettings.DefaultRolesClaim - && authentication.SubjectIdClaim == OpenIdConnectSettings.DefaultSubjectIdClaim - && authentication.SubjectNameClaim == OpenIdConnectSettings.DefaultSubjectNameClaim; - - return defaultClaims ? "Default" : "Custom"; - } - - string ServicePulseOfflineAccess() => - settings.OpenIdConnectSettings.Enabled ? Toggle(settings.OpenIdConnectSettings.ServicePulseOfflineAccessScopeEnabled) : NotApplicable; - - string HttpsHardening() => (settings.HttpsSettings.RedirectHttpToHttps, settings.HttpsSettings.EnableHsts) switch - { - (true, true) => "RedirectAndHsts", - (true, false) => "Redirect", - (false, true) => "Hsts", - _ => "None" - }; - - string ForwardedHeaders() - { - var forwardedHeaders = settings.ForwardedHeadersSettings; - - if (!forwardedHeaders.Enabled) - { - return "Disabled"; - } - - return forwardedHeaders.TrustAllProxies ? "TrustAllProxies" : "KnownProxies"; - } - async ValueTask EmailNotifications(CancellationToken cancellationToken) { var notificationsSettings = await notificationsDataStore.LoadSettings(cancellationToken);