diff --git a/HealthMonitoring.Client/HealthMonitoring.Client.csproj b/HealthMonitoring.Client/HealthMonitoring.Client.csproj index b8ca7c0..2479244 100644 --- a/HealthMonitoring.Client/HealthMonitoring.Client.csproj +++ b/HealthMonitoring.Client/HealthMonitoring.Client.csproj @@ -11,6 +11,7 @@ + diff --git a/HealthMonitoring.Client/HealthMonitoringRegistrationClient.cs b/HealthMonitoring.Client/HealthMonitoringRegistrationClient.cs index 94c796f..bcc9bf9 100644 --- a/HealthMonitoring.Client/HealthMonitoringRegistrationClient.cs +++ b/HealthMonitoring.Client/HealthMonitoringRegistrationClient.cs @@ -1,6 +1,7 @@ using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace HealthMonitoring.Client; @@ -38,7 +39,9 @@ public interface IHealthMonitoringRegistrationClient Task RegisterAndConfigureAsync(ServiceRegistrationOptions options, IEnumerable mappings, CancellationToken cancellationToken = default); } -public sealed class HealthMonitoringRegistrationClient(HttpClient httpClient) : IHealthMonitoringRegistrationClient +public sealed class HealthMonitoringRegistrationClient( + HttpClient httpClient, + ILogger? logger = null) : IHealthMonitoringRegistrationClient { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); @@ -47,7 +50,7 @@ public async Task RegisterAsync(ServiceRegistrationOp ArgumentNullException.ThrowIfNull(options); ValidateServiceOptions(options); - using var response = await httpClient.PostAsJsonAsync("api/services/register", new ServiceRegistrationPayload( + var payload = new ServiceRegistrationPayload( options.ServiceId, options.Name, options.HealthUrl.ToString(), @@ -61,7 +64,19 @@ public async Task RegisterAsync(ServiceRegistrationOp options.Group, options.Description, options.Version, - options.Host), JsonOptions, cancellationToken); + options.Host); + + var loggedPayload = payload with + { + ConnectionString = string.IsNullOrWhiteSpace(payload.ConnectionString) + ? payload.ConnectionString + : "[REDACTED]" + }; + logger?.LogInformation( + "POST /api/services/register body: {RequestBody}", + JsonSerializer.Serialize(loggedPayload, JsonOptions)); + + using var response = await httpClient.PostAsJsonAsync("api/services/register", payload, JsonOptions, cancellationToken); await EnsureSuccessAsync(response, "register service", cancellationToken); var result = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) diff --git a/HealthMonitoring.Tests/Api/ServiceRegistrationLoggingTests.cs b/HealthMonitoring.Tests/Api/ServiceRegistrationLoggingTests.cs new file mode 100644 index 0000000..c0d1a77 --- /dev/null +++ b/HealthMonitoring.Tests/Api/ServiceRegistrationLoggingTests.cs @@ -0,0 +1,23 @@ +using HealthMonitoring.Api; +using HealthMonitoring.Domain; + +namespace HealthMonitoring.Tests.Api; + +public sealed class ServiceRegistrationLoggingTests +{ + [Fact] + public void Registration_body_for_logging_redacts_connection_strings() + { + var request = new ServiceRegistrationRequest("orders-db", "Orders DB", "https://orders/health") + { + MonitorType = MonitorType.SqlServer, + ConnectionString = "Server=orders;Database=Orders;Authentication=Integrated Security" + }; + + var body = ServiceRegistrationLogFormatter.Serialize(request); + + Assert.Contains("orders-db", body); + Assert.Contains("[REDACTED]", body); + Assert.DoesNotContain("Integrated Security", body); + } +} diff --git a/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationClientTests.cs b/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationClientTests.cs index 167cb6f..26feae4 100644 --- a/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationClientTests.cs +++ b/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationClientTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Json; using HealthMonitoring.Client; +using Microsoft.Extensions.Logging; namespace HealthMonitoring.Tests.Client; @@ -47,6 +48,37 @@ public async Task RegisterAsync_posts_service_configuration_and_returns_acknowle Assert.True(payload.IgnoreCertificateErrors); } + [Fact] + public async Task RegisterAsync_logs_the_post_body_without_exposing_connection_strings() + { + var handler = new RecordingHandler(_ => new HttpResponseMessage(HttpStatusCode.Created) + { + Content = JsonContent.Create(new + { + serviceId = PaymentsApiServiceId, + action = "created", + service = new { id = Guid.Parse("11111111-1111-1111-1111-111111111111") } + }) + }); + var logger = new RecordingLogger(); + var client = new HealthMonitoringRegistrationClient( + new HttpClient(handler) { BaseAddress = new Uri(MonitoringBaseUrl) }, + logger); + + await client.RegisterAsync(new ServiceRegistrationOptions + { + ServiceId = PaymentsApiServiceId, + Name = "Payments API", + HealthUrl = new Uri("https://payments-api/health"), + ConnectionString = "Server=secret;Password=super-secret" + }); + + Assert.Contains("payments-api", logger.Messages.Single()); + Assert.Contains("/api/services/register", logger.Messages.Single()); + Assert.Contains("REDACTED", logger.Messages.Single()); + Assert.DoesNotContain("super-secret", logger.Messages.Single()); + } + [Fact] public async Task SetDependencyMappingsAsync_resolves_target_service_and_upserts_mapping() { @@ -133,6 +165,26 @@ protected override Task SendAsync(HttpRequestMessage reques } } + private sealed class RecordingLogger : ILogger + { + public List Messages { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull + { + _ = state; + return null; + } + + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + _ = logLevel; + _ = eventId; + Messages.Add(formatter(state, exception)); + } + } + private static HttpResponseMessage JsonResponse(HttpStatusCode statusCode, object value) => new(statusCode) { Content = JsonContent.Create(value) diff --git a/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs b/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs index 6ae9ff5..ad7c532 100644 --- a/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs +++ b/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs @@ -1,5 +1,7 @@ using HealthMonitoring.Domain; using HealthMonitoring.Persistence; +using System.Text.Json; +using Microsoft.Extensions.Logging; namespace HealthMonitoring.Api; @@ -17,8 +19,12 @@ public static IEndpointRouteBuilder MapServiceRegistrationEndpoints(this IEndpoi } private static void MapRegistrationEndpoint(IEndpointRouteBuilder endpoints, string dashboardEnvironment) => - endpoints.MapPost("/api/services/register", async (ServiceRegistrationRequest request, IHealthMonitoringRepository repository, CancellationToken cancellationToken) => + endpoints.MapPost("/api/services/register", async (ServiceRegistrationRequest request, IHealthMonitoringRepository repository, ILoggerFactory loggerFactory, CancellationToken cancellationToken) => { + loggerFactory.CreateLogger("HealthMonitoring.Api.ServiceRegistrationEndpoints").LogInformation( + "POST /api/services/register body: {RequestBody}", + ServiceRegistrationLogFormatter.Serialize(request)); + var errors = ServiceConfigurationValidator.Validate(request); if (errors.Count > 0) return Results.ValidationProblem(errors.ToDictionary(error => error, error => new[] { error })); @@ -72,3 +78,29 @@ private static void MapArchiveEndpoint(IEndpointRouteBuilder endpoints) => return Results.NoContent(); }); } + +public static class ServiceRegistrationLogFormatter +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public static string Serialize(ServiceRegistrationRequest request) => + JsonSerializer.Serialize(new + { + request.ServiceId, + request.Name, + request.HealthUrl, + request.MonitorType, + ConnectionString = string.IsNullOrWhiteSpace(request.ConnectionString) + ? request.ConnectionString + : "[REDACTED]", + request.IgnoreCertificateErrors, + request.PollingIntervalSeconds, + request.RequestTimeoutSeconds, + request.RetentionDays, + request.StatusPolicy, + request.Group, + request.Description, + request.Version, + request.Host + }, JsonOptions); +} diff --git a/HealthMonitoring/appsettings.json b/HealthMonitoring/appsettings.json index 827c5d3..8a4c2cd 100644 --- a/HealthMonitoring/appsettings.json +++ b/HealthMonitoring/appsettings.json @@ -5,7 +5,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" } }, "Initialization": {