[codex] Enforce console logs hub read permission (#7533)

* Avoid null endpoint DTO metadata in tests

* Enforce console logs hub read permission

* Remove unused console logs hub import

* Support mapped endpoint metadata in auth tests

* Reduce console log capture throughput impact

* Address Copilot console logs review
This commit is contained in:
Sipke Schoorstra 2026-05-23 15:57:27 +02:00 committed by GitHub
parent 27e6b81a6e
commit 0d305d276e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 257 additions and 7 deletions

View file

@ -88,7 +88,7 @@ public class InMemoryConsoleLogProvider(IOptions<ConsoleLogsOptions> options, IC
subscribers = _subscribers.Values.ToList();
foreach (var subscriber in subscribers)
subscriber.TryWrite(summary);
subscriber.TryWrite(summary, _options.SubscriberChannelCapacity);
}
private IReadOnlyCollection<ConsoleLogDroppedSummary> ConsumeDroppedSummaries()
@ -139,10 +139,13 @@ public class InMemoryConsoleLogProvider(IOptions<ConsoleLogsOptions> 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++;
}

View file

@ -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<IConsoleLogsClient>
{
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.");
}
}

View file

@ -20,6 +20,7 @@ public class ConsoleCaptureTee(
private Channel<ConsoleLogLine>? _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)
{

View file

@ -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<HubException>(() => hub.SubscribeAsync(new()));
}
[Fact]
public async Task HubUpdateFilter_WithoutConsoleLogsPermission_DeniesAccess()
{
var hub = CreateHub("write:diagnostics:console-logs");
await Assert.ThrowsAsync<HubException>(() => 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<IEnumerable<string>>(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<ConsoleLogSubscriptionManager>.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<IReadOnlyCollection<ConsoleLogSource>>([]);
}
}
private class TestConsoleLogSourceRegistry : IConsoleLogSourceRegistry
{
public event Action<ConsoleLogSource>? SourceChanged
{
add { }
remove { }
}
public ConsoleLogSource Current { get; } = new()
{
Id = "test",
DisplayName = "Test"
};
public void MarkSeen(string sourceId, DateTimeOffset timestamp)
{
}
public IReadOnlyCollection<ConsoleLogSource> List()
{
return [Current];
}
}
private class TestHubContext : IHubContext<ConsoleLogsHub, IConsoleLogsClient>
{
public IHubClients<IConsoleLogsClient> Clients { get; } = new TestHubClients();
public IGroupManager Groups { get; } = new TestGroupManager();
}
private class TestHubClients : IHubClients<IConsoleLogsClient>
{
public IConsoleLogsClient All => throw new NotSupportedException();
public IConsoleLogsClient AllExcept(IReadOnlyList<string> excludedConnectionIds) => throw new NotSupportedException();
public IConsoleLogsClient Client(string connectionId) => throw new NotSupportedException();
public IConsoleLogsClient Clients(IReadOnlyList<string> connectionIds) => throw new NotSupportedException();
public IConsoleLogsClient Group(string groupName) => throw new NotSupportedException();
public IConsoleLogsClient GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => throw new NotSupportedException();
public IConsoleLogsClient Groups(IReadOnlyList<string> groupNames) => throw new NotSupportedException();
public IConsoleLogsClient User(string userId) => throw new NotSupportedException();
public IConsoleLogsClient Users(IReadOnlyList<string> 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<object, object?> Items { get; } = new Dictionary<object, object?>();
public override IFeatureCollection Features { get; } = new FeatureCollection();
public override CancellationToken ConnectionAborted { get; } = CancellationToken.None;
public override void Abort()
{
}
}
}

View file

@ -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);