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:
256
tests/NATS.Server.Tests/Auth/SystemAccountTests.cs
Normal file
256
tests/NATS.Server.Tests/Auth/SystemAccountTests.cs
Normal file
@@ -0,0 +1,256 @@
|
||||
// Port of Go server/accounts_test.go — TestSystemAccountDefaultCreation,
|
||||
// TestSystemAccountSysSubjectRouting, TestNonSystemAccountCannotSubscribeToSys.
|
||||
// Reference: golang/nats-server/server/accounts_test.go, server.go — initSystemAccount.
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the $SYS system account functionality including:
|
||||
/// - Default system account creation with IsSystemAccount flag
|
||||
/// - $SYS.> subject routing to the system account's SubList
|
||||
/// - Non-system accounts blocked from subscribing to $SYS.> subjects
|
||||
/// - System account event publishing
|
||||
/// Reference: Go server/accounts.go — isSystemAccount, isReservedSubject.
|
||||
/// </summary>
|
||||
public class SystemAccountTests
|
||||
{
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private static int GetFreePort()
|
||||
{
|
||||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
return ((IPEndPoint)sock.LocalEndPoint!).Port;
|
||||
}
|
||||
|
||||
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
|
||||
{
|
||||
var port = GetFreePort();
|
||||
options.Port = port;
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
return (server, port, cts);
|
||||
}
|
||||
|
||||
private static async Task<Socket> RawConnectAsync(int port)
|
||||
{
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, port);
|
||||
var buf = new byte[4096];
|
||||
await sock.ReceiveAsync(buf, SocketFlags.None);
|
||||
return sock;
|
||||
}
|
||||
|
||||
private static async Task<string> ReadUntilAsync(Socket sock, string expected, int timeoutMs = 5000)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
var sb = new StringBuilder();
|
||||
var buf = new byte[4096];
|
||||
while (!sb.ToString().Contains(expected, StringComparison.Ordinal))
|
||||
{
|
||||
int n;
|
||||
try { n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
if (n == 0) break;
|
||||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the server creates a $SYS system account by default with
|
||||
/// IsSystemAccount set to true.
|
||||
/// Reference: Go server/server.go — initSystemAccount.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Default_system_account_is_created()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
server.SystemAccount.ShouldNotBeNull();
|
||||
server.SystemAccount.Name.ShouldBe(Account.SystemAccountName);
|
||||
server.SystemAccount.IsSystemAccount.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the system account constant matches "$SYS".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void System_account_name_constant_is_correct()
|
||||
{
|
||||
Account.SystemAccountName.ShouldBe("$SYS");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a non-system account does not have IsSystemAccount set.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Regular_account_is_not_system_account()
|
||||
{
|
||||
var account = new Account("test-account");
|
||||
account.IsSystemAccount.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IsSystemAccount can be explicitly set on an account.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IsSystemAccount_can_be_set()
|
||||
{
|
||||
var account = new Account("custom-sys") { IsSystemAccount = true };
|
||||
account.IsSystemAccount.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IsSystemSubject correctly identifies $SYS subjects.
|
||||
/// Reference: Go server/server.go — isReservedSubject.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("$SYS", true)]
|
||||
[InlineData("$SYS.ACCOUNT.test.CONNECT", true)]
|
||||
[InlineData("$SYS.SERVER.abc.STATSZ", true)]
|
||||
[InlineData("$SYS.REQ.SERVER.PING.VARZ", true)]
|
||||
[InlineData("foo.bar", false)]
|
||||
[InlineData("$G", false)]
|
||||
[InlineData("SYS.test", false)]
|
||||
[InlineData("$JS.API.STREAM.LIST", false)]
|
||||
[InlineData("$SYS.", true)]
|
||||
public void IsSystemSubject_identifies_sys_subjects(string subject, bool expected)
|
||||
{
|
||||
NatsServer.IsSystemSubject(subject).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the system account is listed among server accounts.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void System_account_is_in_server_accounts()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
var accounts = server.GetAccounts().ToList();
|
||||
accounts.ShouldContain(a => a.Name == Account.SystemAccountName && a.IsSystemAccount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IsSubscriptionAllowed blocks non-system accounts from $SYS.> subjects.
|
||||
/// Reference: Go server/accounts.go — isReservedForSys.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Non_system_account_cannot_subscribe_to_sys_subjects()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
var regularAccount = new Account("regular");
|
||||
|
||||
server.IsSubscriptionAllowed(regularAccount, "$SYS.SERVER.abc.STATSZ").ShouldBeFalse();
|
||||
server.IsSubscriptionAllowed(regularAccount, "$SYS.ACCOUNT.test.CONNECT").ShouldBeFalse();
|
||||
server.IsSubscriptionAllowed(regularAccount, "$SYS.REQ.SERVER.PING.VARZ").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the system account IS allowed to subscribe to $SYS.> subjects.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void System_account_can_subscribe_to_sys_subjects()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
server.IsSubscriptionAllowed(server.SystemAccount, "$SYS.SERVER.abc.STATSZ").ShouldBeTrue();
|
||||
server.IsSubscriptionAllowed(server.SystemAccount, "$SYS.ACCOUNT.test.CONNECT").ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that any account can subscribe to non-$SYS subjects.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Any_account_can_subscribe_to_regular_subjects()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
var regularAccount = new Account("regular");
|
||||
|
||||
server.IsSubscriptionAllowed(regularAccount, "foo.bar").ShouldBeTrue();
|
||||
server.IsSubscriptionAllowed(regularAccount, "$JS.API.STREAM.LIST").ShouldBeTrue();
|
||||
server.IsSubscriptionAllowed(server.SystemAccount, "foo.bar").ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that GetSubListForSubject routes $SYS subjects to the system account's SubList.
|
||||
/// Reference: Go server/server.go — sublist routing for internal subjects.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetSubListForSubject_routes_sys_to_system_account()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
var globalAccount = server.GetOrCreateAccount(Account.GlobalAccountName);
|
||||
|
||||
// $SYS subjects should route to the system account's SubList
|
||||
var sysList = server.GetSubListForSubject(globalAccount, "$SYS.SERVER.abc.STATSZ");
|
||||
sysList.ShouldBeSameAs(server.SystemAccount.SubList);
|
||||
|
||||
// Regular subjects should route to the specified account's SubList
|
||||
var regularList = server.GetSubListForSubject(globalAccount, "foo.bar");
|
||||
regularList.ShouldBeSameAs(globalAccount.SubList);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the EventSystem publishes to the system account's SubList
|
||||
/// and that internal subscriptions for monitoring are registered there.
|
||||
/// The subscriptions are wired up during StartAsync via InitEventTracking.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Event_system_subscribes_in_system_account()
|
||||
{
|
||||
var (server, _, cts) = await StartServerAsync(new NatsOptions());
|
||||
try
|
||||
{
|
||||
// The system account's SubList should have subscriptions registered
|
||||
// by the internal event system (VARZ, HEALTHZ, etc.)
|
||||
server.EventSystem.ShouldNotBeNull();
|
||||
server.SystemAccount.SubList.Count.ShouldBeGreaterThan(0u);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the global account is separate from the system account.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Global_and_system_accounts_are_separate()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
var globalAccount = server.GetOrCreateAccount(Account.GlobalAccountName);
|
||||
var systemAccount = server.SystemAccount;
|
||||
|
||||
globalAccount.ShouldNotBeSameAs(systemAccount);
|
||||
globalAccount.Name.ShouldBe(Account.GlobalAccountName);
|
||||
systemAccount.Name.ShouldBe(Account.SystemAccountName);
|
||||
globalAccount.IsSystemAccount.ShouldBeFalse();
|
||||
systemAccount.IsSystemAccount.ShouldBeTrue();
|
||||
globalAccount.SubList.ShouldNotBeSameAs(systemAccount.SubList);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user