feat(networking): add leaf subject filtering and port networking Go tests (D6+D7)

D6: Add ExportSubjects/ImportSubjects allow-lists to LeafHubSpokeMapper alongside
existing DenyExports/DenyImports deny-lists. When an allow-list is non-empty, subjects
must match at least one allow pattern; deny always takes precedence. Updated
LeafNodeOptions, LeafHubSpokeMapper (5-arg constructor), and LeafNodeManager to wire
through the new allow-lists. Added 13 new unit + integration tests covering allow-list
semantics, deny precedence, bidirectional filtering, and wire-level propagation.

D7: Existing NetworkingGoParityTests.cs (50 tests) covers gateway interest mode,
route pool accounting, and leaf node connections. Parity DB already up to date.
This commit is contained in:
Joseph Doherty
2026-02-24 16:07:33 -05:00
parent 02531dda58
commit 37d3cc29ea
4 changed files with 467 additions and 19 deletions

View File

@@ -28,4 +28,22 @@ public sealed class LeafNodeOptions
/// Go reference: leafnode.go — DenyImports in RemoteLeafOpts (opts.go:230).
/// </summary>
public List<string> DenyImports { get; set; } = [];
/// <summary>
/// Explicit allow-list for exported subjects (hub→leaf direction). When non-empty,
/// only messages matching at least one of these patterns will be forwarded from
/// the hub to the leaf. Deny patterns (<see cref="DenyExports"/>) take precedence.
/// Supports wildcards (* and >).
/// Go reference: auth.go — SubjectPermission.Allow (Publish allow list).
/// </summary>
public List<string> ExportSubjects { get; set; } = [];
/// <summary>
/// Explicit allow-list for imported subjects (leaf→hub direction). When non-empty,
/// only messages matching at least one of these patterns will be forwarded from
/// the leaf to the hub. Deny patterns (<see cref="DenyImports"/>) take precedence.
/// Supports wildcards (* and >).
/// Go reference: auth.go — SubjectPermission.Allow (Subscribe allow list).
/// </summary>
public List<string> ImportSubjects { get; set; } = [];
}

View File

@@ -12,10 +12,16 @@ public sealed record LeafMappingResult(string Account, string Subject);
/// <summary>
/// Maps accounts between hub and spoke, and applies subject-level export/import
/// filtering on leaf connections. In the Go server, DenyExports restricts what
/// flows hub→leaf (Publish permission) and DenyImports restricts what flows
/// leaf→hub (Subscribe permission).
/// Go reference: leafnode.go:470-507 (newLeafNodeCfg), opts.go:230-231.
/// filtering on leaf connections. Supports both allow-lists and deny-lists:
///
/// - <b>ExportSubjects</b> (allow) + <b>DenyExports</b> (deny): controls hub→leaf flow.
/// - <b>ImportSubjects</b> (allow) + <b>DenyImports</b> (deny): controls leaf→hub flow.
///
/// When an allow-list is non-empty, a subject must match at least one allow pattern.
/// A subject matching any deny pattern is always rejected (deny takes precedence).
///
/// Go reference: leafnode.go:470-507 (newLeafNodeCfg), opts.go:230-231,
/// auth.go:127 (SubjectPermission with Allow + Deny).
/// </summary>
public sealed class LeafHubSpokeMapper
{
@@ -23,27 +29,46 @@ public sealed class LeafHubSpokeMapper
private readonly IReadOnlyDictionary<string, string> _spokeToHub;
private readonly IReadOnlyList<string> _denyExports;
private readonly IReadOnlyList<string> _denyImports;
private readonly IReadOnlyList<string> _allowExports;
private readonly IReadOnlyList<string> _allowImports;
public LeafHubSpokeMapper(IReadOnlyDictionary<string, string> hubToSpoke)
: this(hubToSpoke, [], [])
: this(hubToSpoke, [], [], [], [])
{
}
/// <summary>
/// Creates a mapper with account mapping and subject deny filters.
/// Creates a mapper with account mapping and subject deny filters (legacy constructor).
/// </summary>
/// <param name="hubToSpoke">Account mapping from hub account names to spoke account names.</param>
/// <param name="denyExports">Subject patterns to deny in hub→leaf (outbound) direction.</param>
/// <param name="denyImports">Subject patterns to deny in leaf→hub (inbound) direction.</param>
public LeafHubSpokeMapper(
IReadOnlyDictionary<string, string> hubToSpoke,
IReadOnlyList<string> denyExports,
IReadOnlyList<string> denyImports)
: this(hubToSpoke, denyExports, denyImports, [], [])
{
}
/// <summary>
/// Creates a mapper with account mapping, deny filters, and allow-list filters.
/// </summary>
/// <param name="hubToSpoke">Account mapping from hub account names to spoke account names.</param>
/// <param name="denyExports">Subject patterns to deny in hub→leaf (outbound) direction.</param>
/// <param name="denyImports">Subject patterns to deny in leaf→hub (inbound) direction.</param>
/// <param name="allowExports">Subject patterns to allow in hub→leaf (outbound) direction. Empty = allow all.</param>
/// <param name="allowImports">Subject patterns to allow in leaf→hub (inbound) direction. Empty = allow all.</param>
public LeafHubSpokeMapper(
IReadOnlyDictionary<string, string> hubToSpoke,
IReadOnlyList<string> denyExports,
IReadOnlyList<string> denyImports,
IReadOnlyList<string> allowExports,
IReadOnlyList<string> allowImports)
{
_hubToSpoke = hubToSpoke;
_spokeToHub = hubToSpoke.ToDictionary(static p => p.Value, static p => p.Key, StringComparer.Ordinal);
_denyExports = denyExports;
_denyImports = denyImports;
_allowExports = allowExports;
_allowImports = allowImports;
}
/// <summary>
@@ -61,23 +86,36 @@ public sealed class LeafHubSpokeMapper
/// <summary>
/// Returns true if the subject is allowed to flow in the given direction.
/// A subject is denied if it matches any pattern in the corresponding deny list.
/// Go reference: leafnode.go:475-484 (DenyExports → Publish deny, DenyImports → Subscribe deny).
/// When an allow-list is set, the subject must also match at least one allow pattern.
/// Deny takes precedence over allow (Go reference: auth.go SubjectPermission semantics).
/// </summary>
public bool IsSubjectAllowed(string subject, LeafMapDirection direction)
{
var denyList = direction switch
var (denyList, allowList) = direction switch
{
LeafMapDirection.Outbound => _denyExports,
LeafMapDirection.Inbound => _denyImports,
_ => [],
LeafMapDirection.Outbound => (_denyExports, _allowExports),
LeafMapDirection.Inbound => (_denyImports, _allowImports),
_ => ((IReadOnlyList<string>)[], (IReadOnlyList<string>)[]),
};
// Deny takes precedence: if subject matches any deny pattern, reject it.
for (var i = 0; i < denyList.Count; i++)
{
if (SubjectMatch.MatchLiteral(subject, denyList[i]))
return false;
}
return true;
// If allow-list is empty, everything not denied is allowed.
if (allowList.Count == 0)
return true;
// With a non-empty allow-list, subject must match at least one allow pattern.
for (var i = 0; i < allowList.Count; i++)
{
if (SubjectMatch.MatchLiteral(subject, allowList[i]))
return true;
}
return false;
}
}

View File

@@ -59,7 +59,9 @@ public sealed class LeafNodeManager : IAsyncDisposable
_subjectFilter = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
options.DenyExports,
options.DenyImports);
options.DenyImports,
options.ExportSubjects,
options.ImportSubjects);
}
public Task StartAsync(CancellationToken ct)