feat(config): add system account, SIGHUP reload, and auth change propagation (E6+E7+E8)

E6: Add IsSystemAccount property to Account, mark $SYS account as system,
add IsSystemSubject/IsSubscriptionAllowed/GetSubListForSubject helpers to
route $SYS.> subjects to the system account's SubList and block non-system
accounts from subscribing.

E7: Add ConfigReloader.ReloadAsync and ApplyDiff for structured async reload,
add ConfigReloadResult/ConfigApplyResult types. SIGHUP handler already wired
via PosixSignalRegistration in HandleSignals.

E8: Add PropagateAuthChanges to re-evaluate connected clients after auth
config reload, disconnecting clients whose credentials no longer pass
authentication with -ERR 'Authorization Violation'.
This commit is contained in:
Joseph Doherty
2026-02-24 15:48:48 -05:00
parent 18acd6f4e2
commit c6ecbbfbcc
12 changed files with 3143 additions and 4 deletions

View File

@@ -0,0 +1,497 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Configuration;
using NATS.Server.LeafNodes;
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests.LeafNodes;
/// <summary>
/// Tests for leaf node subject filtering via DenyExports and DenyImports.
/// Go reference: leafnode.go:470-507 (newLeafNodeCfg), opts.go:230-231
/// (DenyImports/DenyExports fields in RemoteLeafOpts).
/// </summary>
public class LeafSubjectFilterTests
{
// ── LeafHubSpokeMapper.IsSubjectAllowed Unit Tests ────────────────
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public void Literal_deny_export_blocks_outbound_subject()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: ["secret.data"],
denyImports: []);
mapper.IsSubjectAllowed("secret.data", LeafMapDirection.Outbound).ShouldBeFalse();
mapper.IsSubjectAllowed("public.data", LeafMapDirection.Outbound).ShouldBeTrue();
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public void Literal_deny_import_blocks_inbound_subject()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: [],
denyImports: ["internal.status"]);
mapper.IsSubjectAllowed("internal.status", LeafMapDirection.Inbound).ShouldBeFalse();
mapper.IsSubjectAllowed("external.status", LeafMapDirection.Inbound).ShouldBeTrue();
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public void Wildcard_deny_export_blocks_matching_subjects()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: ["admin.*"],
denyImports: []);
mapper.IsSubjectAllowed("admin.users", LeafMapDirection.Outbound).ShouldBeFalse();
mapper.IsSubjectAllowed("admin.config", LeafMapDirection.Outbound).ShouldBeFalse();
mapper.IsSubjectAllowed("admin.deep.nested", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("public.data", LeafMapDirection.Outbound).ShouldBeTrue();
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public void Fwc_deny_import_blocks_all_matching_subjects()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: [],
denyImports: ["_SYS.>"]);
mapper.IsSubjectAllowed("_SYS.heartbeat", LeafMapDirection.Inbound).ShouldBeFalse();
mapper.IsSubjectAllowed("_SYS.a.b.c", LeafMapDirection.Inbound).ShouldBeFalse();
mapper.IsSubjectAllowed("user.data", LeafMapDirection.Inbound).ShouldBeTrue();
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public void Bidirectional_filtering_applies_independently()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: ["export.denied"],
denyImports: ["import.denied"]);
// Export deny does not affect inbound direction
mapper.IsSubjectAllowed("export.denied", LeafMapDirection.Inbound).ShouldBeTrue();
mapper.IsSubjectAllowed("export.denied", LeafMapDirection.Outbound).ShouldBeFalse();
// Import deny does not affect outbound direction
mapper.IsSubjectAllowed("import.denied", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("import.denied", LeafMapDirection.Inbound).ShouldBeFalse();
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public void Multiple_deny_patterns_all_evaluated()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: ["admin.*", "secret.>", "internal.config"],
denyImports: []);
mapper.IsSubjectAllowed("admin.users", LeafMapDirection.Outbound).ShouldBeFalse();
mapper.IsSubjectAllowed("secret.key.value", LeafMapDirection.Outbound).ShouldBeFalse();
mapper.IsSubjectAllowed("internal.config", LeafMapDirection.Outbound).ShouldBeFalse();
mapper.IsSubjectAllowed("public.data", LeafMapDirection.Outbound).ShouldBeTrue();
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public void Empty_deny_lists_allow_everything()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: [],
denyImports: []);
mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Inbound).ShouldBeTrue();
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public void Account_mapping_still_works_with_subject_filter()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string> { ["HUB_ACCT"] = "SPOKE_ACCT" },
denyExports: ["denied.>"],
denyImports: []);
var outbound = mapper.Map("HUB_ACCT", "foo.bar", LeafMapDirection.Outbound);
outbound.Account.ShouldBe("SPOKE_ACCT");
outbound.Subject.ShouldBe("foo.bar");
var inbound = mapper.Map("SPOKE_ACCT", "foo.bar", LeafMapDirection.Inbound);
inbound.Account.ShouldBe("HUB_ACCT");
inbound.Subject.ShouldBe("foo.bar");
mapper.IsSubjectAllowed("denied.test", LeafMapDirection.Outbound).ShouldBeFalse();
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public void Default_constructor_allows_everything()
{
var mapper = new LeafHubSpokeMapper(new Dictionary<string, string>());
mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Inbound).ShouldBeTrue();
}
// ── Integration: DenyExports blocks hub→leaf message forwarding ────
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public async Task DenyExports_blocks_message_forwarding_hub_to_leaf()
{
// Start a hub with DenyExports configured
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
DenyExports = ["secret.>"],
},
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
try
{
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
try
{
// Wait for leaf connection
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
await hubConn.ConnectAsync();
// Subscribe on spoke for allowed and denied subjects
await using var allowedSub = await leafConn.SubscribeCoreAsync<string>("public.data");
await using var deniedSub = await leafConn.SubscribeCoreAsync<string>("secret.data");
await leafConn.PingAsync();
// Wait for interest propagation
await Task.Delay(500);
// Publish from hub
await hubConn.PublishAsync("public.data", "allowed-msg");
await hubConn.PublishAsync("secret.data", "denied-msg");
// The allowed message should arrive
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
(await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("allowed-msg");
// The denied message should NOT arrive
using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await deniedSub.Msgs.ReadAsync(leakCts.Token));
}
finally
{
await spokeCts.CancelAsync();
spoke.Dispose();
spokeCts.Dispose();
}
}
finally
{
await hubCts.CancelAsync();
hub.Dispose();
hubCts.Dispose();
}
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public async Task DenyImports_blocks_message_forwarding_leaf_to_hub()
{
// Start hub with DenyImports — leaf→hub messages for denied subjects are dropped
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
DenyImports = ["private.>"],
},
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
try
{
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
try
{
// Wait for leaf connection
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
await hubConn.ConnectAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
await leafConn.ConnectAsync();
// Subscribe on hub for both allowed and denied subjects
await using var allowedSub = await hubConn.SubscribeCoreAsync<string>("public.data");
await using var deniedSub = await hubConn.SubscribeCoreAsync<string>("private.data");
await hubConn.PingAsync();
// Wait for interest propagation
await Task.Delay(500);
// Publish from spoke (leaf)
await leafConn.PublishAsync("public.data", "allowed-msg");
await leafConn.PublishAsync("private.data", "denied-msg");
// The allowed message should arrive on hub
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
(await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("allowed-msg");
// The denied message should NOT arrive
using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await deniedSub.Msgs.ReadAsync(leakCts.Token));
}
finally
{
await spokeCts.CancelAsync();
spoke.Dispose();
spokeCts.Dispose();
}
}
finally
{
await hubCts.CancelAsync();
hub.Dispose();
hubCts.Dispose();
}
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public async Task DenyExports_with_wildcard_blocks_pattern_matching_subjects()
{
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
DenyExports = ["admin.*"],
},
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
try
{
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
await hubConn.ConnectAsync();
// admin.users should be blocked; admin.deep.nested should pass (* doesn't match multi-token)
await using var blockedSub = await leafConn.SubscribeCoreAsync<string>("admin.users");
await using var allowedSub = await leafConn.SubscribeCoreAsync<string>("admin.deep.nested");
await leafConn.PingAsync();
await Task.Delay(500);
await hubConn.PublishAsync("admin.users", "blocked");
await hubConn.PublishAsync("admin.deep.nested", "allowed");
// The multi-token subject passes because * matches only single token
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
(await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("allowed");
// The single-token subject is blocked
using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await blockedSub.Msgs.ReadAsync(leakCts.Token));
}
finally
{
await spokeCts.CancelAsync();
spoke.Dispose();
spokeCts.Dispose();
}
}
finally
{
await hubCts.CancelAsync();
hub.Dispose();
hubCts.Dispose();
}
}
// ── Wire-level: DenyExports blocks LS+ propagation ──────────────
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public async Task DenyExports_blocks_subscription_propagation()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var options = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
DenyExports = ["secret.>"],
};
var manager = new LeafNodeManager(
options,
new ServerStats(),
"HUB1",
_ => { },
_ => { },
NullLogger<LeafNodeManager>.Instance);
await manager.StartAsync(CancellationToken.None);
try
{
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, options.Port);
// Exchange handshakes — inbound connections send LEAF first, then read response
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await WriteLineAsync(remoteSocket, "LEAF SPOKE1", cts.Token);
var line = await ReadLineAsync(remoteSocket, cts.Token);
line.ShouldStartWith("LEAF ");
await Task.Delay(200);
// Propagate allowed subscription
manager.PropagateLocalSubscription("$G", "public.data", null);
await Task.Delay(100);
var lsLine = await ReadLineAsync(remoteSocket, cts.Token);
lsLine.ShouldBe("LS+ $G public.data");
// Propagate denied subscription — should NOT appear on wire
manager.PropagateLocalSubscription("$G", "secret.data", null);
// Send a PING to verify nothing else was sent
manager.PropagateLocalSubscription("$G", "allowed.check", null);
await Task.Delay(100);
var nextLine = await ReadLineAsync(remoteSocket, cts.Token);
nextLine.ShouldBe("LS+ $G allowed.check");
}
finally
{
await manager.DisposeAsync();
}
}
// ── Helpers ────────────────────────────────────────────────────────
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
{
var bytes = new List<byte>(64);
var single = new byte[1];
while (true)
{
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
if (read == 0)
break;
if (single[0] == (byte)'\n')
break;
if (single[0] != (byte)'\r')
bytes.Add(single[0]);
}
return Encoding.ASCII.GetString([.. bytes]);
}
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
}