feat: add JWT permission template expansion with cartesian product for multi-value tags
This commit is contained in:
123
src/NATS.Server/Auth/Jwt/PermissionTemplates.cs
Normal file
123
src/NATS.Server/Auth/Jwt/PermissionTemplates.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace NATS.Server.Auth.Jwt;
|
||||
|
||||
/// <summary>
|
||||
/// Expands mustache-style template strings in NATS JWT permission subjects.
|
||||
/// When a user connects with a JWT, template strings in their permissions are
|
||||
/// expanded using claim values from the user and account JWTs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reference: Go auth.go:424-520 — processUserPermissionsTemplate()
|
||||
///
|
||||
/// Supported template functions:
|
||||
/// {{name()}} — user's Name claim
|
||||
/// {{subject()}} — user's Subject (NKey public key)
|
||||
/// {{tag(tagname)}} — user tags matching "tagname:" prefix (multi-value → cartesian product)
|
||||
/// {{account-name()}} — account display name
|
||||
/// {{account-subject()}} — account NKey public key
|
||||
/// {{account-tag(tagname)}} — account tags matching "tagname:" prefix (multi-value → cartesian product)
|
||||
///
|
||||
/// When a template resolves to multiple values (e.g. a user with two "dept:" tags),
|
||||
/// the cartesian product of all expanded subjects is returned. If any template
|
||||
/// resolves to zero values, the entire pattern is dropped (returns empty list).
|
||||
/// </remarks>
|
||||
public static partial class PermissionTemplates
|
||||
{
|
||||
[GeneratedRegex(@"\{\{([^}]+)\}\}")]
|
||||
private static partial Regex TemplateRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Expands a single permission pattern containing zero or more template expressions.
|
||||
/// Returns the list of concrete subjects after substitution.
|
||||
/// Returns an empty list if any template resolves to no values (tag not found).
|
||||
/// Returns a single-element list containing the original pattern if no templates are present.
|
||||
/// </summary>
|
||||
public static List<string> Expand(
|
||||
string pattern,
|
||||
string name, string subject,
|
||||
string accountName, string accountSubject,
|
||||
string[] userTags, string[] accountTags)
|
||||
{
|
||||
var matches = TemplateRegex().Matches(pattern);
|
||||
if (matches.Count == 0)
|
||||
return [pattern];
|
||||
|
||||
var replacements = new List<(string Placeholder, string[] Values)>();
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
var expr = match.Groups[1].Value.Trim();
|
||||
var values = ResolveTemplate(expr, name, subject, accountName, accountSubject, userTags, accountTags);
|
||||
if (values.Length == 0)
|
||||
return [];
|
||||
replacements.Add((match.Value, values));
|
||||
}
|
||||
|
||||
// Compute cartesian product across all multi-value replacements.
|
||||
// Start with the full pattern and iteratively replace each placeholder.
|
||||
var results = new List<string> { pattern };
|
||||
foreach (var (placeholder, values) in replacements)
|
||||
{
|
||||
var next = new List<string>();
|
||||
foreach (var current in results)
|
||||
foreach (var value in values)
|
||||
next.Add(current.Replace(placeholder, value));
|
||||
results = next;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands all patterns in a permission list, flattening multi-value expansions
|
||||
/// into the result. Patterns that resolve to no values are omitted entirely.
|
||||
/// </summary>
|
||||
public static List<string> ExpandAll(
|
||||
IEnumerable<string> patterns,
|
||||
string name, string subject,
|
||||
string accountName, string accountSubject,
|
||||
string[] userTags, string[] accountTags)
|
||||
{
|
||||
var result = new List<string>();
|
||||
foreach (var pattern in patterns)
|
||||
result.AddRange(Expand(pattern, name, subject, accountName, accountSubject, userTags, accountTags));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string[] ResolveTemplate(
|
||||
string expr,
|
||||
string name, string subject,
|
||||
string accountName, string accountSubject,
|
||||
string[] userTags, string[] accountTags)
|
||||
{
|
||||
return expr.ToLowerInvariant() switch
|
||||
{
|
||||
"name()" => [name],
|
||||
"subject()" => [subject],
|
||||
"account-name()" => [accountName],
|
||||
"account-subject()" => [accountSubject],
|
||||
_ when expr.StartsWith("tag(", StringComparison.OrdinalIgnoreCase) => ResolveTags(expr, userTags),
|
||||
_ when expr.StartsWith("account-tag(", StringComparison.OrdinalIgnoreCase) => ResolveTags(expr, accountTags),
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the tag name from a tag() or account-tag() expression and returns
|
||||
/// all matching tag values from the provided tags array.
|
||||
/// Tags are stored in "key:value" format; this method returns the value portion.
|
||||
/// </summary>
|
||||
private static string[] ResolveTags(string expr, string[] tags)
|
||||
{
|
||||
var openParen = expr.IndexOf('(');
|
||||
var closeParen = expr.IndexOf(')');
|
||||
if (openParen < 0 || closeParen < 0)
|
||||
return [];
|
||||
|
||||
var tagName = expr[(openParen + 1)..closeParen].Trim();
|
||||
var prefix = tagName + ":";
|
||||
return tags
|
||||
.Where(t => t.StartsWith(prefix, StringComparison.Ordinal))
|
||||
.Select(t => t[prefix.Length..])
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
99
tests/NATS.Server.Tests/PermissionTemplateTests.cs
Normal file
99
tests/NATS.Server.Tests/PermissionTemplateTests.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
using NATS.Server.Auth.Jwt;
|
||||
|
||||
public class PermissionTemplateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Expand_name_template()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("user.{{name()}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: [], accountTags: []);
|
||||
result.ShouldBe(["user.alice.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_subject_template()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("inbox.{{subject()}}.>",
|
||||
name: "alice", subject: "UABC123", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: [], accountTags: []);
|
||||
result.ShouldBe(["inbox.UABC123.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_account_name_template()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("acct.{{account-name()}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "myaccount", accountSubject: "AABC",
|
||||
userTags: [], accountTags: []);
|
||||
result.ShouldBe(["acct.myaccount.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_account_subject_template()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("acct.{{account-subject()}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC456",
|
||||
userTags: [], accountTags: []);
|
||||
result.ShouldBe(["acct.AABC456.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_tag_template_single_value()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("dept.{{tag(dept)}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: ["dept:engineering"], accountTags: []);
|
||||
result.ShouldBe(["dept.engineering.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_tag_template_multi_value_cartesian()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("dept.{{tag(dept)}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: ["dept:eng", "dept:sales"], accountTags: []);
|
||||
result.Count.ShouldBe(2);
|
||||
result.ShouldContain("dept.eng.>");
|
||||
result.ShouldContain("dept.sales.>");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_account_tag_template()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("region.{{account-tag(region)}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: [], accountTags: ["region:us-east"]);
|
||||
result.ShouldBe(["region.us-east.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_no_templates_returns_original()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("foo.bar.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: [], accountTags: []);
|
||||
result.ShouldBe(["foo.bar.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_unknown_tag_returns_empty()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("dept.{{tag(missing)}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: ["dept:eng"], accountTags: []);
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExpandAll_expands_array_of_subjects()
|
||||
{
|
||||
var subjects = new[] { "user.{{name()}}.>", "inbox.{{subject()}}.>" };
|
||||
var result = PermissionTemplates.ExpandAll(subjects,
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: [], accountTags: []);
|
||||
result.ShouldBe(["user.alice.>", "inbox.UABC.>"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user