[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:
parent
27e6b81a6e
commit
0d305d276e
|
|
@ -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++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue