diff --git a/src/NATS.Server/Auth/Jwt/PermissionTemplates.cs b/src/NATS.Server/Auth/Jwt/PermissionTemplates.cs new file mode 100644 index 0000000..59e63f0 --- /dev/null +++ b/src/NATS.Server/Auth/Jwt/PermissionTemplates.cs @@ -0,0 +1,123 @@ +using System.Text.RegularExpressions; + +namespace NATS.Server.Auth.Jwt; + +/// +/// 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. +/// +/// +/// 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). +/// +public static partial class PermissionTemplates +{ + [GeneratedRegex(@"\{\{([^}]+)\}\}")] + private static partial Regex TemplateRegex(); + + /// + /// 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. + /// + public static List 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 { pattern }; + foreach (var (placeholder, values) in replacements) + { + var next = new List(); + foreach (var current in results) + foreach (var value in values) + next.Add(current.Replace(placeholder, value)); + results = next; + } + return results; + } + + /// + /// Expands all patterns in a permission list, flattening multi-value expansions + /// into the result. Patterns that resolve to no values are omitted entirely. + /// + public static List ExpandAll( + IEnumerable patterns, + string name, string subject, + string accountName, string accountSubject, + string[] userTags, string[] accountTags) + { + var result = new List(); + 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), + _ => [] + }; + } + + /// + /// 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. + /// + 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(); + } +} diff --git a/tests/NATS.Server.Tests/PermissionTemplateTests.cs b/tests/NATS.Server.Tests/PermissionTemplateTests.cs new file mode 100644 index 0000000..9e9de7c --- /dev/null +++ b/tests/NATS.Server.Tests/PermissionTemplateTests.cs @@ -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.>"]); + } +}