diff --git a/src/NATS.Server/Auth/Account.cs b/src/NATS.Server/Auth/Account.cs index c67a1e6..b8332eb 100644 --- a/src/NATS.Server/Auth/Account.cs +++ b/src/NATS.Server/Auth/Account.cs @@ -661,6 +661,82 @@ public sealed class Account : IDisposable } } + // Account claim hot-reload support. + // Go reference: server/accounts.go — UpdateAccountClaims / updateAccountClaimsWithRefresh (~line 3287). + private AccountClaimData? _currentClaims; + private int _claimUpdateCount; + + /// The most recently applied claim data, or null if no claims have been applied. + public AccountClaimData? CurrentClaims => Volatile.Read(ref _currentClaims); + + /// Returns true if at least one claim update has been applied to this account. + public bool HasClaims => Volatile.Read(ref _currentClaims) != null; + + /// Total number of successful (changed) claim updates applied to this account. + public int ClaimUpdateCount => Volatile.Read(ref _claimUpdateCount); + + /// + /// Applies to the account using diff-based comparison. + /// Only changed fields are updated. When any field changes, the generation counter is + /// incremented so that per-client permission caches are invalidated. + /// Go reference: server/accounts.go UpdateAccountClaims / updateAccountClaimsWithRefresh (~line 3287). + /// + public AccountClaimUpdateResult UpdateAccountClaims(AccountClaimData newClaims) + { + ArgumentNullException.ThrowIfNull(newClaims); + + var old = Volatile.Read(ref _currentClaims); + var changedFields = new List(); + + if (old == null) + { + // First-time application — every present field is considered "changed". + if (newClaims.MaxConnections.HasValue) + changedFields.Add(nameof(AccountClaimData.MaxConnections)); + if (newClaims.MaxSubscriptions.HasValue) + changedFields.Add(nameof(AccountClaimData.MaxSubscriptions)); + if (newClaims.Nkey != null) + changedFields.Add(nameof(AccountClaimData.Nkey)); + if (newClaims.Issuer != null) + changedFields.Add(nameof(AccountClaimData.Issuer)); + if (newClaims.ExpiresAt.HasValue) + changedFields.Add(nameof(AccountClaimData.ExpiresAt)); + } + else + { + if (old.MaxConnections != newClaims.MaxConnections) + changedFields.Add(nameof(AccountClaimData.MaxConnections)); + if (old.MaxSubscriptions != newClaims.MaxSubscriptions) + changedFields.Add(nameof(AccountClaimData.MaxSubscriptions)); + if (!string.Equals(old.Nkey, newClaims.Nkey, StringComparison.Ordinal)) + changedFields.Add(nameof(AccountClaimData.Nkey)); + if (!string.Equals(old.Issuer, newClaims.Issuer, StringComparison.Ordinal)) + changedFields.Add(nameof(AccountClaimData.Issuer)); + if (old.ExpiresAt != newClaims.ExpiresAt) + changedFields.Add(nameof(AccountClaimData.ExpiresAt)); + } + + bool changed = changedFields.Count > 0; + + if (changed) + { + // Apply changed fields to the live account properties. + if (newClaims.MaxConnections.HasValue) + MaxConnections = newClaims.MaxConnections.Value; + if (newClaims.MaxSubscriptions.HasValue) + MaxSubscriptions = newClaims.MaxSubscriptions.Value; + Nkey = newClaims.Nkey; + Issuer = newClaims.Issuer; + + Volatile.Write(ref _currentClaims, newClaims); + var updateCount = Interlocked.Increment(ref _claimUpdateCount); + IncrementGeneration(); + return new AccountClaimUpdateResult(Changed: true, ChangedFields: changedFields, UpdateCount: updateCount); + } + + return new AccountClaimUpdateResult(Changed: false, ChangedFields: [], UpdateCount: Volatile.Read(ref _claimUpdateCount)); + } + public void Dispose() => SubList.Dispose(); } @@ -748,3 +824,40 @@ public sealed record ActivationCheckResult( bool IsExpired, DateTime? ExpiresAt, TimeSpan? TimeToExpiry); + +/// +/// Snapshot of account JWT claim fields used for hot-reload diff detection. +/// Go reference: server/accounts.go — AccountClaims / jwt.AccountClaims fields applied in updateAccountClaimsWithRefresh (~line 3374). +/// +public sealed class AccountClaimData +{ + /// Maximum number of connections (null = not specified by claim). + public int? MaxConnections { get; init; } + + /// Maximum number of subscriptions (null = not specified by claim). + public int? MaxSubscriptions { get; init; } + + /// The account's public NKey, if present in the claim. + public string? Nkey { get; init; } + + /// The operator or signing key that issued the account JWT. + public string? Issuer { get; init; } + + /// The UTC expiry time encoded in the claim, if present. + public DateTime? ExpiresAt { get; init; } + + /// Arbitrary tags associated with the account in the JWT. + public Dictionary Tags { get; init; } = new(); +} + +/// +/// Result of describing which fields changed. +/// Go reference: server/accounts.go — updateAccountClaimsWithRefresh output (~line 3374). +/// +/// Whether any field differed from the previous claim state. +/// Names of the properties that changed. +/// The new total claim update count after this call. +public sealed record AccountClaimUpdateResult( + bool Changed, + IReadOnlyList ChangedFields, + int UpdateCount); diff --git a/tests/NATS.Server.Tests/Auth/AccountClaimReloadTests.cs b/tests/NATS.Server.Tests/Auth/AccountClaimReloadTests.cs new file mode 100644 index 0000000..96731f6 --- /dev/null +++ b/tests/NATS.Server.Tests/Auth/AccountClaimReloadTests.cs @@ -0,0 +1,173 @@ +// Tests for account claim hot-reload with diff-based update detection. +// Go reference: accounts_test.go TestUpdateAccountClaims, updateAccountClaimsWithRefresh (~line 3374). + +using NATS.Server.Auth; + +namespace NATS.Server.Tests.Auth; + +public class AccountClaimReloadTests +{ + // 1. First update: all provided fields are reported as changed. + [Fact] + public void UpdateAccountClaims_FirstUpdate_AllFieldsChanged() + { + var account = new Account("test"); + var claims = new AccountClaimData + { + MaxConnections = 10, + MaxSubscriptions = 100, + Nkey = "NKEY123", + Issuer = "ISSUER_OP", + ExpiresAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc), + }; + + var result = account.UpdateAccountClaims(claims); + + result.Changed.ShouldBeTrue(); + result.ChangedFields.ShouldContain(nameof(AccountClaimData.MaxConnections)); + result.ChangedFields.ShouldContain(nameof(AccountClaimData.MaxSubscriptions)); + result.ChangedFields.ShouldContain(nameof(AccountClaimData.Nkey)); + result.ChangedFields.ShouldContain(nameof(AccountClaimData.Issuer)); + result.ChangedFields.ShouldContain(nameof(AccountClaimData.ExpiresAt)); + result.ChangedFields.Count.ShouldBe(5); + } + + // 2. Applying the exact same claims a second time returns Changed=false. + [Fact] + public void UpdateAccountClaims_NoChange_ReturnsFalse() + { + var account = new Account("test"); + var claims = new AccountClaimData + { + MaxConnections = 5, + MaxSubscriptions = 50, + Nkey = "NKEY_A", + Issuer = "OP", + }; + + account.UpdateAccountClaims(claims); + var result = account.UpdateAccountClaims(claims); + + result.Changed.ShouldBeFalse(); + result.ChangedFields.Count.ShouldBe(0); + } + + // 3. Changing MaxConnections is detected. + [Fact] + public void UpdateAccountClaims_MaxConnectionsChanged_Detected() + { + var account = new Account("test"); + var initial = new AccountClaimData { MaxConnections = 10 }; + account.UpdateAccountClaims(initial); + + var updated = new AccountClaimData { MaxConnections = 20 }; + var result = account.UpdateAccountClaims(updated); + + result.Changed.ShouldBeTrue(); + result.ChangedFields.ShouldContain(nameof(AccountClaimData.MaxConnections)); + account.MaxConnections.ShouldBe(20); + } + + // 4. Changing MaxSubscriptions is detected. + [Fact] + public void UpdateAccountClaims_MaxSubscriptionsChanged_Detected() + { + var account = new Account("test"); + var initial = new AccountClaimData { MaxSubscriptions = 100 }; + account.UpdateAccountClaims(initial); + + var updated = new AccountClaimData { MaxSubscriptions = 200 }; + var result = account.UpdateAccountClaims(updated); + + result.Changed.ShouldBeTrue(); + result.ChangedFields.ShouldContain(nameof(AccountClaimData.MaxSubscriptions)); + account.MaxSubscriptions.ShouldBe(200); + } + + // 5. Changing Nkey is detected. + [Fact] + public void UpdateAccountClaims_NkeyChanged_Detected() + { + var account = new Account("test"); + var initial = new AccountClaimData { Nkey = "OLD_NKEY" }; + account.UpdateAccountClaims(initial); + + var updated = new AccountClaimData { Nkey = "NEW_NKEY" }; + var result = account.UpdateAccountClaims(updated); + + result.Changed.ShouldBeTrue(); + result.ChangedFields.ShouldContain(nameof(AccountClaimData.Nkey)); + account.Nkey.ShouldBe("NEW_NKEY"); + } + + // 6. Changing Issuer is detected. + [Fact] + public void UpdateAccountClaims_IssuerChanged_Detected() + { + var account = new Account("test"); + var initial = new AccountClaimData { Issuer = "ISSUER_A" }; + account.UpdateAccountClaims(initial); + + var updated = new AccountClaimData { Issuer = "ISSUER_B" }; + var result = account.UpdateAccountClaims(updated); + + result.Changed.ShouldBeTrue(); + result.ChangedFields.ShouldContain(nameof(AccountClaimData.Issuer)); + account.Issuer.ShouldBe("ISSUER_B"); + } + + // 7. A successful claim update increments the generation counter. + [Fact] + public void UpdateAccountClaims_IncrementsGeneration() + { + var account = new Account("test"); + var before = account.GenerationId; + + var claims = new AccountClaimData { MaxConnections = 5 }; + account.UpdateAccountClaims(claims); + + account.GenerationId.ShouldBe(before + 1); + } + + // 8. HasClaims is false on a fresh account. + [Fact] + public void HasClaims_BeforeUpdate_ReturnsFalse() + { + var account = new Account("test"); + account.HasClaims.ShouldBeFalse(); + } + + // 9. HasClaims is true after the first update. + [Fact] + public void HasClaims_AfterUpdate_ReturnsTrue() + { + var account = new Account("test"); + var claims = new AccountClaimData { MaxConnections = 1 }; + account.UpdateAccountClaims(claims); + + account.HasClaims.ShouldBeTrue(); + account.CurrentClaims.ShouldNotBeNull(); + account.CurrentClaims!.MaxConnections.ShouldBe(1); + } + + // 10. ClaimUpdateCount increments only when claims actually change. + [Fact] + public void ClaimUpdateCount_IncrementsOnChange() + { + var account = new Account("test"); + account.ClaimUpdateCount.ShouldBe(0); + + var claimsA = new AccountClaimData { MaxConnections = 5 }; + account.UpdateAccountClaims(claimsA); + account.ClaimUpdateCount.ShouldBe(1); + + // Reapplying same claims does NOT increment count. + account.UpdateAccountClaims(claimsA); + account.ClaimUpdateCount.ShouldBe(1); + + // Applying different claims does increment. + var claimsB = new AccountClaimData { MaxConnections = 10 }; + account.UpdateAccountClaims(claimsB); + account.ClaimUpdateCount.ShouldBe(2); + } +} diff --git a/tests/NATS.Server.Tests/Auth/ActivationExpirationTests.cs b/tests/NATS.Server.Tests/Auth/ActivationExpirationTests.cs new file mode 100644 index 0000000..9b3fb6c --- /dev/null +++ b/tests/NATS.Server.Tests/Auth/ActivationExpirationTests.cs @@ -0,0 +1,200 @@ +// Tests for Account JWT activation claim expiration: RegisterActivation, +// CheckActivationExpiry, IsActivationExpired, GetExpiredActivations, +// RemoveExpiredActivations, and ActiveActivationCount. +// Go reference: server/accounts.go — checkActivation (~line 2943), +// activationExpired (~line 2920). + +using NATS.Server.Auth; + +namespace NATS.Server.Tests.Auth; + +public class ActivationExpirationTests +{ + // Helpers for well-past / well-future dates to avoid timing flakiness. + private static DateTime WellFuture => DateTime.UtcNow.AddDays(30); + private static DateTime WellPast => DateTime.UtcNow.AddDays(-30); + + private static ActivationClaim ValidClaim(string subject) => new() + { + Subject = subject, + IssuedAt = DateTime.UtcNow.AddDays(-1), + ExpiresAt = WellFuture, + Issuer = "AABC123", + }; + + private static ActivationClaim ExpiredClaim(string subject) => new() + { + Subject = subject, + IssuedAt = DateTime.UtcNow.AddDays(-60), + ExpiresAt = WellPast, + Issuer = "AABC123", + }; + + // --------------------------------------------------------------------------- + // RegisterActivation + // --------------------------------------------------------------------------- + + [Fact] + public void RegisterActivation_StoresActivation() + { + // Go ref: accounts.go — checkActivation stores the decoded activation claim. + var account = new Account("test"); + var claim = ValidClaim("svc.foo"); + + account.RegisterActivation("svc.foo", claim); + + var result = account.CheckActivationExpiry("svc.foo"); + result.Found.ShouldBeTrue(); + } + + // --------------------------------------------------------------------------- + // CheckActivationExpiry + // --------------------------------------------------------------------------- + + [Fact] + public void CheckActivationExpiry_Valid_NotExpired() + { + // Go ref: accounts.go — act.Expires > tn ⇒ checkActivation returns true (not expired). + var account = new Account("test"); + account.RegisterActivation("svc.valid", ValidClaim("svc.valid")); + + var result = account.CheckActivationExpiry("svc.valid"); + + result.Found.ShouldBeTrue(); + result.IsExpired.ShouldBeFalse(); + result.ExpiresAt.ShouldNotBeNull(); + result.TimeToExpiry.ShouldNotBeNull(); + result.TimeToExpiry!.Value.ShouldBeGreaterThan(TimeSpan.Zero); + } + + [Fact] + public void CheckActivationExpiry_Expired_ReturnsExpired() + { + // Go ref: accounts.go — act.Expires <= tn ⇒ checkActivation returns false (expired). + var account = new Account("test"); + account.RegisterActivation("svc.expired", ExpiredClaim("svc.expired")); + + var result = account.CheckActivationExpiry("svc.expired"); + + result.Found.ShouldBeTrue(); + result.IsExpired.ShouldBeTrue(); + result.ExpiresAt.ShouldNotBeNull(); + result.TimeToExpiry.ShouldBe(TimeSpan.Zero); + } + + [Fact] + public void CheckActivationExpiry_NotFound() + { + // Go ref: accounts.go — checkActivation returns false when claim is nil/empty token. + var account = new Account("test"); + + var result = account.CheckActivationExpiry("svc.unknown"); + + result.Found.ShouldBeFalse(); + result.IsExpired.ShouldBeFalse(); + result.ExpiresAt.ShouldBeNull(); + result.TimeToExpiry.ShouldBeNull(); + } + + // --------------------------------------------------------------------------- + // IsActivationExpired + // --------------------------------------------------------------------------- + + [Fact] + public void IsActivationExpired_Valid_ReturnsFalse() + { + // Go ref: accounts.go — act.Expires > tn ⇒ not expired. + var account = new Account("test"); + account.RegisterActivation("svc.ok", ValidClaim("svc.ok")); + + account.IsActivationExpired("svc.ok").ShouldBeFalse(); + } + + [Fact] + public void IsActivationExpired_Expired_ReturnsTrue() + { + // Go ref: accounts.go — act.Expires <= tn ⇒ expired, activationExpired fires. + var account = new Account("test"); + account.RegisterActivation("svc.past", ExpiredClaim("svc.past")); + + account.IsActivationExpired("svc.past").ShouldBeTrue(); + } + + // --------------------------------------------------------------------------- + // GetExpiredActivations + // --------------------------------------------------------------------------- + + [Fact] + public void GetExpiredActivations_ReturnsOnlyExpired() + { + // Go ref: accounts.go — activationExpired is called only for expired claims. + var account = new Account("test"); + account.RegisterActivation("svc.a", ValidClaim("svc.a")); + account.RegisterActivation("svc.b", ExpiredClaim("svc.b")); + account.RegisterActivation("svc.c", ValidClaim("svc.c")); + account.RegisterActivation("svc.d", ExpiredClaim("svc.d")); + + var expired = account.GetExpiredActivations(); + + expired.Count.ShouldBe(2); + expired.ShouldContain("svc.b"); + expired.ShouldContain("svc.d"); + expired.ShouldNotContain("svc.a"); + expired.ShouldNotContain("svc.c"); + } + + // --------------------------------------------------------------------------- + // RemoveExpiredActivations + // --------------------------------------------------------------------------- + + [Fact] + public void RemoveExpiredActivations_RemovesAndReturnsCount() + { + // Go ref: accounts.go — activationExpired removes the import when activation expires. + var account = new Account("test"); + account.RegisterActivation("svc.live", ValidClaim("svc.live")); + account.RegisterActivation("svc.gone1", ExpiredClaim("svc.gone1")); + account.RegisterActivation("svc.gone2", ExpiredClaim("svc.gone2")); + + var removed = account.RemoveExpiredActivations(); + + removed.ShouldBe(2); + + // The expired ones should no longer be found. + account.CheckActivationExpiry("svc.gone1").Found.ShouldBeFalse(); + account.CheckActivationExpiry("svc.gone2").Found.ShouldBeFalse(); + + // The live one should still be registered. + account.CheckActivationExpiry("svc.live").Found.ShouldBeTrue(); + } + + // --------------------------------------------------------------------------- + // ActiveActivationCount + // --------------------------------------------------------------------------- + + [Fact] + public void ActiveActivationCount_ExcludesExpired() + { + // Go ref: accounts.go — only non-expired activations are considered active. + var account = new Account("test"); + account.RegisterActivation("svc.1", ValidClaim("svc.1")); + account.RegisterActivation("svc.2", ValidClaim("svc.2")); + account.RegisterActivation("svc.3", ExpiredClaim("svc.3")); + + account.ActiveActivationCount.ShouldBe(2); + } + + // --------------------------------------------------------------------------- + // ActivationClaim.TimeToExpiry + // --------------------------------------------------------------------------- + + [Fact] + public void ActivationClaim_TimeToExpiry_Zero_WhenExpired() + { + // Go ref: accounts.go — expired activation has no remaining time. + var claim = ExpiredClaim("svc.expired"); + + claim.IsExpired.ShouldBeTrue(); + claim.TimeToExpiry.ShouldBe(TimeSpan.Zero); + } +}