feat: add JWT permission template expansion with cartesian product for multi-value tags

This commit is contained in:
Joseph Doherty
2026-02-23 04:33:45 -05:00
parent a406832bfa
commit d0af741eb8
2 changed files with 222 additions and 0 deletions

View 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();
}
}

View 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.>"]);
}
}