diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Providers/InMemory/InMemoryConsoleLogProvider.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Providers/InMemory/InMemoryConsoleLogProvider.cs index 857628f87..f3fbf2bf6 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Providers/InMemory/InMemoryConsoleLogProvider.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Providers/InMemory/InMemoryConsoleLogProvider.cs @@ -88,7 +88,7 @@ public class InMemoryConsoleLogProvider(IOptions options, IC subscribers = _subscribers.Values.ToList(); foreach (var subscriber in subscribers) - subscriber.TryWrite(summary); + subscriber.TryWrite(summary, _options.SubscriberChannelCapacity); } private IReadOnlyCollection ConsumeDroppedSummaries() @@ -139,10 +139,13 @@ public class InMemoryConsoleLogProvider(IOptions options, IC } } - public void TryWrite(ConsoleLogDroppedSummary summary) + public void TryWrite(ConsoleLogDroppedSummary summary, int capacity) { lock (_lock) { + if (_pendingItemCount >= capacity) + return; + Channel.Writer.TryWrite(ConsoleLogStreamItem.FromDroppedLines(summary)); _pendingItemCount++; } diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ConsoleLogsHub.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ConsoleLogsHub.cs index 23ff6a5ce..be37a50af 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ConsoleLogsHub.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ConsoleLogsHub.cs @@ -1,3 +1,5 @@ +using Elsa.Diagnostics.ConsoleLogs.Permissions; +using FastEndpoints.Security; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; @@ -6,12 +8,20 @@ namespace Elsa.Diagnostics.ConsoleLogs.RealTime; [Authorize] public class ConsoleLogsHub(ConsoleLogSubscriptionManager subscriptionManager) : Hub { + private const string ReadAllPermission = "read:*"; + private static readonly string[] ReadPermissions = [PermissionNames.All, ReadAllPermission, ConsoleLogsPermissions.Read]; + public Task SubscribeAsync(ConsoleLogFilter? filter) { + EnsureCanReadConsoleLogs(); return subscriptionManager.SubscribeAsync(Context.ConnectionId, ValidateFilter(filter), Context.ConnectionAborted); } - public Task UpdateFilterAsync(ConsoleLogFilter? filter) => subscriptionManager.UpdateFilterAsync(Context.ConnectionId, ValidateFilter(filter), Context.ConnectionAborted); + public Task UpdateFilterAsync(ConsoleLogFilter? filter) + { + EnsureCanReadConsoleLogs(); + return subscriptionManager.UpdateFilterAsync(Context.ConnectionId, ValidateFilter(filter), Context.ConnectionAborted); + } public Task UnsubscribeAsync() { @@ -33,4 +43,12 @@ public class ConsoleLogsHub(ConsoleLogSubscriptionManager subscriptionManager) : return filter; } + + private void EnsureCanReadConsoleLogs() + { + var user = Context.User; + + if (user?.Identity?.IsAuthenticated != true || !ReadPermissions.Any(user.HasPermission)) + throw new HubException("Access denied."); + } } diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleCaptureTee.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleCaptureTee.cs index 23e2dbd8d..3bd64ac61 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleCaptureTee.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleCaptureTee.cs @@ -20,6 +20,7 @@ public class ConsoleCaptureTee( private Channel? _publishChannel; private CancellationTokenSource? _publishCancellation; private Task? _publishTask; + private int _startCount; private long _sequence; public override Encoding Encoding => _originalOut?.Encoding ?? Encoding.UTF8; @@ -28,7 +29,7 @@ public class ConsoleCaptureTee( { lock (_lock) { - if (_originalOut != null || _originalError != null) + if (_startCount++ > 0) return ValueTask.CompletedTask; _publishCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -55,6 +56,12 @@ public class ConsoleCaptureTee( lock (_lock) { + if (_startCount == 0) + return ValueTask.CompletedTask; + + if (--_startCount > 0) + return ValueTask.CompletedTask; + if (_originalOut != null) Console.SetOut(_originalOut); @@ -135,7 +142,7 @@ public class ConsoleCaptureTee( Truncated = formatted.Truncated }; - if (_publishChannel?.Writer.TryWrite(redactor.Redact(line)) != false) + if (_publishChannel?.Writer.TryWrite(line) != false) return; if (provider is IConsoleLogDroppedLineReporter reporter) @@ -150,7 +157,7 @@ public class ConsoleCaptureTee( { try { - await provider.PublishAsync(line, cancellationToken); + await provider.PublishAsync(redactor.Redact(line), cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { diff --git a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsAuthorizationTests.cs b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsAuthorizationTests.cs index b2452cf5b..1387ee8f3 100644 --- a/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsAuthorizationTests.cs +++ b/test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsAuthorizationTests.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Security.Claims; using Elsa.Diagnostics.ConsoleLogs.Contracts; using Elsa.Diagnostics.ConsoleLogs.Features; using Elsa.Diagnostics.ConsoleLogs.Models; @@ -6,6 +7,9 @@ using Elsa.Diagnostics.ConsoleLogs.Permissions; using Elsa.Diagnostics.ConsoleLogs.RealTime; using FastEndpoints; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging.Abstractions; namespace Elsa.Diagnostics.ConsoleLogs.IntegrationTests; @@ -19,6 +23,33 @@ public class ConsoleLogsAuthorizationTests Assert.Null(authorize.Policy); } + [Fact] + public async Task HubSubscribe_WithoutConsoleLogsPermission_DeniesAccess() + { + var hub = CreateHub("write:diagnostics:console-logs"); + + await Assert.ThrowsAsync(() => hub.SubscribeAsync(new())); + } + + [Fact] + public async Task HubUpdateFilter_WithoutConsoleLogsPermission_DeniesAccess() + { + var hub = CreateHub("write:diagnostics:console-logs"); + + await Assert.ThrowsAsync(() => hub.UpdateFilterAsync(new())); + } + + [Theory] + [InlineData(ConsoleLogsPermissions.Read)] + [InlineData(PermissionNames.All)] + [InlineData("read:*")] + public async Task HubSubscribe_WithConsoleLogsPermission_AllowsAccess(string permission) + { + var hub = CreateHub(permission); + + await hub.SubscribeAsync(new()); + } + [Theory] [InlineData("Elsa.Diagnostics.ConsoleLogs.Endpoints.ConsoleLogs.Recent.Endpoint")] [InlineData("Elsa.Diagnostics.ConsoleLogs.Endpoints.ConsoleLogs.Sources.Endpoint")] @@ -33,7 +64,8 @@ public class ConsoleLogsAuthorizationTests { var endpointType = typeof(ConsoleLogsFeature).Assembly.GetType(endpointTypeName, throwOnError: true)!; var endpoint = Activator.CreateInstance(endpointType, new TestConsoleLogProvider())!; - var definition = new EndpointDefinition(endpointType, requestDtoType: null!, responseDtoType: null!); + var (requestDtoType, responseDtoType) = GetEndpointDtoTypes(endpointType); + var definition = new EndpointDefinition(endpointType, requestDtoType, responseDtoType); endpointType .GetProperty("Definition", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)! @@ -49,6 +81,57 @@ public class ConsoleLogsAuthorizationTests return Assert.IsAssignableFrom>(permissions).ToArray(); } + private static (Type RequestDtoType, Type ResponseDtoType) GetEndpointDtoTypes(Type endpointType) + { + var type = endpointType; + + while (type.BaseType != null) + { + type = type.BaseType; + + if (!type.IsGenericType) + continue; + + var genericTypeDefinition = type.GetGenericTypeDefinition(); + var genericArguments = type.GetGenericArguments(); + + if (genericTypeDefinition == typeof(Elsa.Abstractions.ElsaEndpoint<,>)) + return (genericArguments[0], genericArguments[1]); + + if (genericTypeDefinition == typeof(Elsa.Abstractions.ElsaEndpoint<,,>)) + return (genericArguments[0], genericArguments[1]); + + if (genericTypeDefinition == typeof(Elsa.Abstractions.ElsaEndpointWithoutRequest<>)) + return (typeof(EmptyRequest), genericArguments[0]); + } + + throw new InvalidOperationException($"Unsupported endpoint type '{endpointType.FullName}'."); + } + + private static ConsoleLogsHub CreateHub(params string[] permissions) + { + var provider = new TestConsoleLogProvider(); + var sourceRegistry = new TestConsoleLogSourceRegistry(); + var hubContext = new TestHubContext(); + var subscriptionManager = new ConsoleLogSubscriptionManager(provider, sourceRegistry, hubContext, NullLogger.Instance); + + return new ConsoleLogsHub(subscriptionManager) + { + Context = new TestHubCallerContext(CreateUser(permissions)) + }; + } + + private static ClaimsPrincipal CreateUser(params string[] permissions) + { + var permissionClaimType = (string)typeof(SecurityOptions) + .GetProperty(nameof(SecurityOptions.PermissionsClaimType))! + .GetValue(new Config().Security)!; + var claims = permissions.Select(x => new Claim(permissionClaimType, x)); + var identity = new ClaimsIdentity(claims, "Test"); + + return new ClaimsPrincipal(identity); + } + private class TestConsoleLogProvider : IConsoleLogProvider { public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) @@ -71,4 +154,75 @@ public class ConsoleLogsAuthorizationTests return ValueTask.FromResult>([]); } } + + private class TestConsoleLogSourceRegistry : IConsoleLogSourceRegistry + { + public event Action? SourceChanged + { + add { } + remove { } + } + + public ConsoleLogSource Current { get; } = new() + { + Id = "test", + DisplayName = "Test" + }; + + public void MarkSeen(string sourceId, DateTimeOffset timestamp) + { + } + + public IReadOnlyCollection List() + { + return [Current]; + } + } + + private class TestHubContext : IHubContext + { + public IHubClients Clients { get; } = new TestHubClients(); + + public IGroupManager Groups { get; } = new TestGroupManager(); + } + + private class TestHubClients : IHubClients + { + public IConsoleLogsClient All => throw new NotSupportedException(); + public IConsoleLogsClient AllExcept(IReadOnlyList excludedConnectionIds) => throw new NotSupportedException(); + public IConsoleLogsClient Client(string connectionId) => throw new NotSupportedException(); + public IConsoleLogsClient Clients(IReadOnlyList connectionIds) => throw new NotSupportedException(); + public IConsoleLogsClient Group(string groupName) => throw new NotSupportedException(); + public IConsoleLogsClient GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => throw new NotSupportedException(); + public IConsoleLogsClient Groups(IReadOnlyList groupNames) => throw new NotSupportedException(); + public IConsoleLogsClient User(string userId) => throw new NotSupportedException(); + public IConsoleLogsClient Users(IReadOnlyList userIds) => throw new NotSupportedException(); + } + + private class TestGroupManager : IGroupManager + { + public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + } + + private class TestHubCallerContext(ClaimsPrincipal user) : HubCallerContext + { + public override string ConnectionId { get; } = "connection-1"; + public override string? UserIdentifier { get; } = "user-1"; + public override ClaimsPrincipal? User { get; } = user; + public override IDictionary Items { get; } = new Dictionary(); + public override IFeatureCollection Features { get; } = new FeatureCollection(); + public override CancellationToken ConnectionAborted { get; } = CancellationToken.None; + + public override void Abort() + { + } + } } diff --git a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleCaptureTeeTests.cs b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleCaptureTeeTests.cs index 6815d38c0..a5b0c9026 100644 --- a/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleCaptureTeeTests.cs +++ b/test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleCaptureTeeTests.cs @@ -61,6 +61,74 @@ public class ConsoleCaptureTeeTests Assert.Single(provider.Lines); } finally + { + await capture.StopAsync(); + await capture.StopAsync(); + Console.SetOut(originalOut); + Console.SetError(originalError); + } + } + + [Fact] + public async Task StartAsync_RedactsBeforePublishingToProvider() + { + var originalOut = Console.Out; + var originalError = Console.Error; + using var consoleOutput = new StringWriter(); + var provider = new CapturingProvider(); + var registry = new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions())); + var options = Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()); + var capture = new ConsoleCaptureTee(provider, registry, new ConsoleLogRedactor(options), new ConsoleLineFormatter(options), options); + + try + { + Console.SetOut(consoleOutput); + await capture.StartAsync(); + + Console.WriteLine("token=secret-value"); + await WaitForLineAsync(provider); + + Assert.Equal("[Redacted]", provider.Lines[0].Text); + } + finally + { + await capture.StopAsync(); + Console.SetOut(originalOut); + Console.SetError(originalError); + } + } + + [Fact] + public async Task StopAsync_WhenStartedTwice_KeepsCaptureActiveUntilSecondStop() + { + var originalOut = Console.Out; + var originalError = Console.Error; + using var consoleOutput = new StringWriter(); + var provider = new CapturingProvider(); + var registry = new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions())); + var options = Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()); + var capture = new ConsoleCaptureTee(provider, registry, new ConsoleLogRedactor(options), new ConsoleLineFormatter(options), options); + + try + { + Console.SetOut(consoleOutput); + await capture.StartAsync(); + await capture.StartAsync(); + + await capture.StopAsync(); + Console.WriteLine("still captured"); + await WaitForLineAsync(provider); + + Assert.Single(provider.Lines); + Assert.Equal("still captured", provider.Lines[0].Text); + + await capture.StopAsync(); + Console.WriteLine("not captured"); + await Task.Delay(50); + + Assert.Single(provider.Lines); + } + finally { await capture.StopAsync(); Console.SetOut(originalOut);