Repository: nats-io/nats-server
Pull request #3367 (2022-08-12 18:35:33)

Adding templates to scoped signing key user permis
For security reasons we have introduced scoped signing keys to jwt.
They carry user permissions.
Wich is why jwt issued by those keys are not allowed to carry their own permission.
Instead they are copied from the signing key.
If the scoped signing key gets compromised, an attacker can only issue jwt with the permissions of the key.
With a plain signing key, an attacker can create arbitrary user with permissions.
Because user jwt creation is greatly simplified we added a single utility function to go/java/.net which issues user for such keys.
This is function is documented in ADR-14:

```
/**
 * signingKey, is a mandatory account nkey pair to sign the generated jwt.
 * accountId, is a mandatory public account nkey. Will return error when not set or not account nkey.
 * publicUserKey, is a mandatory public user nkey. Will return error when not set or not user nkey.
 * name, optional human readable name. When absent, default to publicUserKey.
 * expiration, optional but recommended duration, when the generated jwt needs to expire. If not set, JWT will not expire.
 * tags, optional list of tags to be included in the JWT.
 *
 * Returns:
 * error, when issues arose.
 * string, resulting jwt.
 **/
IssueUserJWT(signingKey nkey, accountId string, publicUserKey string, name string, expiration time.Duration, tags []string) (error, string)
```

Currently the only downside of this is that the permissions are static and can't be tailored to the user.

This PR changes that by allowing the user pub/sub permissions to be parameterized with templates.

templates are for entire tokens only and include:
{{name()}} -> username
{{subject()}} -> user subject (nkey)
{{account-name()}} -> users account name
{{account-subject()}} -> user accoutn subject (nkey)

{{tag(arbitrary-prefix)}}
provided the tag "arbitrary-prefix:value" will result in "value"
provided the tags ["arbitrary-prefix:1", "arbitrary-prefix:2"] will result in two subjects "1" & "2"

If the resulting subject is not valid.
Say a tag is not present or name is not set.
This will result in an error for deny subjects
and result in no subject for allow subject.

Signed-off-by: Matthias Hanel <mh@synadia.com>


matthiashanel: > LGTM - We still allow unfettered signing keys though correct?

yes we do. 

Patch:
diff --git a/server/auth.go b/server/auth.go
index d2295c39..72d1fc0b 100644
--- a/server/auth.go
+++ b/server/auth.go
@@ -371,6 +371,161 @@ func (c *client) matchesPinnedCert(tlsPinnedCerts PinnedCertSet) bool {
 	return true
 }
 
+func processUserPermissionsTemplate(lim jwt.UserPermissionLimits, ujwt *jwt.UserClaims, acc *Account) (jwt.UserPermissionLimits, error) {
+	nArrayCartesianProduct := func(a ...[]string) [][]string {
+		c := 1
+		for _, a := range a {
+			c *= len(a)
+		}
+		if c == 0 {
+			return nil
+		}
+		p := make([][]string, c)
+		b := make([]string, c*len(a))
+		n := make([]int, len(a))
+		s := 0
+		for i := range p {
+			e := s + len(a)
+			pi := b[s:e]
+			p[i] = pi
+			s = e
+			for j, n := range n {
+				pi[j] = a[j][n]
+			}
+			for j := len(n) - 1; j >= 0; j-- {
+				n[j]++
+				if n[j] < len(a[j]) {
+					break
+				}
+				n[j] = 0
+			}
+		}
+		return p
+	}
+	applyTemplate := func(list jwt.StringList, failOnBadSubject bool) (jwt.StringList, error) {
+		found := false
+	FOR_FIND:
+		for i := 0; i < len(list); i++ {
+			// check if templates are present
+			for _, tk := range strings.Split(list[i], tsep) {
+				if strings.HasPrefix(tk, "{{") && strings.HasSuffix(tk, "}}") {
+					found = true
+					break FOR_FIND
+				}
+			}
+		}
+		if !found {
+			return list, nil
+		}
+		// process the templates
+		emittedList := make([]string, 0, len(list))
+		for i := 0; i < len(list); i++ {
+			tokens := strings.Split(list[i], tsep)
+
+			newTokens := make([]string, len(tokens))
+			tagValues := map[int][]string{} // indexed by token
+
+			for tokenNum, tk := range tokens {
+				if strings.HasPrefix(tk, "{{") && strings.HasSuffix(tk, "}}") {
+					op := strings.ToLower(strings.TrimSuffix(strings.TrimPrefix(tk, "{{"), "}}"))
+					switch {
+					case op == "name()":
+						tk = ujwt.Name
+					case op == "subject()":
+						tk = ujwt.Subject
+					case op == "account-name()":
+						acc.mu.RLock()
+						name := acc.nameTag
+						acc.mu.RUnlock()
+						tk = name
+					case op == "account-subject()":
+						tk = ujwt.IssuerAccount
+					case (strings.HasPrefix(op, "tag(") || strings.HasPrefix(op, "account-tag(")) &&
+						strings.HasSuffix(op, ")"):
+						// insert dummy tav value that will throw of subject validation (in case nothing is found)
+						tk = _EMPTY_
+						// collect list of matching tag values
+
+						var tags jwt.TagList
+						var tagPrefix string
+						if strings.HasPrefix(op, "account-tag(") {
+							acc.mu.RLock()
+							tags = acc.tags
+							acc.mu.RUnlock()
+							tagPrefix = fmt.Sprintf("%s:", strings.ToLower(
+								strings.TrimSuffix(strings.TrimPrefix(op, "account-tag("), ")")))
+						} else {
+							tags = ujwt.Tags
+							tagPrefix = fmt.Sprintf("%s:", strings.ToLower(
+								strings.TrimSuffix(strings.TrimPrefix(op, "tag("), ")")))
+						}
+
+						for _, tag := range tags {
+							if strings.HasPrefix(tag, tagPrefix) {
+								tagValue := strings.TrimPrefix(tag, tagPrefix)
+								tagValues[tokenNum] = append(tagValues[tokenNum], tagValue)
+							}
+						}
+					default:
+						// if macro is not recognized, throw off subject check on purpose
+						tk = " "
+					}
+				}
+				newTokens[tokenNum] = tk
+			}
+			// fill in tag value placeholders
+			if len(tagValues) == 0 {
+				emitSubj := strings.Join(newTokens, tsep)
+				if IsValidSubject(emitSubj) {
+					emittedList = append(emittedList, emitSubj)
+				} else if failOnBadSubject {
+					return nil, fmt.Errorf("generated invalid subject")
+				}
+				// else skip emitting
+			} else {
+				orderedList := make([][]string, 0, len(tagValues))
+				for _, valueList := range tagValues {
+					orderedList = append(orderedList, valueList)
+				}
+				// compute the cartesian product and compute subject to emit for each combination
+				for _, valueList := range nArrayCartesianProduct(orderedList...) {
+					b := strings.Builder{}
+					for i, token := range newTokens {
+						if token == _EMPTY_ {
+							b.WriteString(valueList[0])
+							valueList = valueList[1:]
+						} else {
+							b.WriteString(token)
+						}
+						if i != len(newTokens)-1 {
+							b.WriteString(tsep)
+						}
+					}
+					emitSubj := b.String()
+					if IsValidSubject(emitSubj) {
+						emittedList = append(emittedList, emitSubj)
+					} else if failOnBadSubject {
+						return nil, fmt.Errorf("generated invalid subject")
+					}
+					// else skip emitting
+				}
+			}
+		}
+		return emittedList, nil
+	}
+	var err error
+	if lim.Permissions.Sub.Allow, err = applyTemplate(lim.Permissions.Sub.Allow, false); err != nil {
+		return jwt.UserPermissionLimits{}, err
+	} else if lim.Permissions.Sub.Deny, err = applyTemplate(lim.Permissions.Sub.Deny, true); err != nil {
+		return jwt.UserPermissionLimits{}, err
+	} else if lim.Permissions.Pub.Allow, err = applyTemplate(lim.Permissions.Pub.Allow, false); err != nil {
+		return jwt.UserPermissionLimits{}, err
+	} else if lim.Permissions.Pub.Deny, err = applyTemplate(lim.Permissions.Pub.Deny, true); err != nil {
+		return jwt.UserPermissionLimits{}, err
+	}
+	return lim, nil
+}
+
 func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) bool {
 	var (
 		nkey *NkeyUser
@@ -621,8 +776,9 @@ func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) boo
 			} else if uSc, ok := scope.(*jwt.UserScope); !ok {
 				c.Debugf("User JWT is not valid")
 				return false
-			} else {
-				juc.UserPermissionLimits = uSc.Template
+			} else if juc.UserPermissionLimits, err = processUserPermissionsTemplate(uSc.Template, juc, acc); err != nil {
+				c.Debugf("User JWT is not valid")
+				return false
 			}
 		}
 		if acc.IsExpired() {


Test patch:
diff --git a/server/jwt_test.go b/server/jwt_test.go
index 561b7e95..189ab22d 100644
--- a/server/jwt_test.go
+++ b/server/jwt_test.go
@@ -4163,6 +4163,119 @@ func TestJWTLimits(t *testing.T) {
 	})
 }
 
+func TestJwtTemplates(t *testing.T) {
+	kp, _ := nkeys.CreateAccount()
+	aPub, _ := kp.PublicKey()
+	ukp, _ := nkeys.CreateUser()
+	upub, _ := ukp.PublicKey()
+	uclaim := newJWTTestUserClaims()
+	uclaim.Name = "myname"
+	uclaim.Subject = upub
+	uclaim.SetScoped(true)
+	uclaim.IssuerAccount = aPub
+	uclaim.Tags.Add("foo:foo1")
+	uclaim.Tags.Add("foo:foo2")
+	uclaim.Tags.Add("bar:bar1")
+	uclaim.Tags.Add("bar:bar2")
+	uclaim.Tags.Add("bar:bar3")
+
+	lim := jwt.UserPermissionLimits{}
+	lim.Pub.Allow.Add("{{tag(foo)}}.none.{{tag(bar)}}")
+	lim.Pub.Deny.Add("{{tag(foo)}}.{{account-tag(acc)}}")
+	lim.Sub.Allow.Add("{{tag(NOT_THERE)}}") // expect to not emit this
+	lim.Sub.Deny.Add("foo.{{name()}}.{{subject()}}.{{account-name()}}.{{account-subject()}}.bar")
+	acc := &Account{nameTag: "accname", tags: []string{"acc:acc1", "acc:acc2"}}
+
+	resLim, err := processUserPermissionsTemplate(lim, uclaim, acc)
+	require_NoError(t, err)
+
+	test := func(expectedSubjects []string, res jwt.StringList) {
+		t.Helper()
+		require_True(t, len(res) == len(expectedSubjects))
+		for _, expetedSubj := range expectedSubjects {
+			require_True(t, res.Contains(expetedSubj))
+		}
+	}
+
+	test(resLim.Pub.Allow, []string{"foo1.none.bar1", "foo1.none.bar2", "foo1.none.bar3",
+		"foo2.none.bar1", "foo2.none.bar2", "foo2.none.bar3"})
+
+	test(resLim.Pub.Deny, []string{"foo1.acc1", "foo1.acc2", "foo2.acc1", "foo2.acc2"})
+
+	require_True(t, len(resLim.Sub.Allow) == 0)
+	require_True(t, len(resLim.Sub.Deny) == 1)
+	require_Contains(t, resLim.Sub.Deny[0], fmt.Sprintf("foo.myname.%s.accname.%s.bar", upub, aPub))
+}
+
+func TestJWTLimitsTemplate(t *testing.T) {
+	kp, _ := nkeys.CreateAccount()
+	aPub, _ := kp.PublicKey()
+	claim := jwt.NewAccountClaims(aPub)
+	aSignScopedKp, aSignScopedPub := createKey(t)
+	signer := jwt.NewUserScope()
+	signer.Key = aSignScopedPub
+	signer.Template.Pub.Deny.Add("denied")
+	signer.Template.Pub.Allow.Add("foo.{{name()}}")
+	signer.Template.Sub.Allow.Add("foo.{{name()}}")
+	claim.SigningKeys.AddScopedSigner(signer)
+	aJwt, err := claim.Encode(oKp)
+	require_NoError(t, err)
+	conf := createConfFile(t, []byte(fmt.Sprintf(`
+		listen: 127.0.0.1:-1
+		operator: %s
+		resolver: MEM
+		resolver_preload: {
+			%s: %s
+		}
+    `, ojwt, aPub, aJwt)))
+	defer removeFile(t, conf)
+	sA, _ := RunServerWithConfig(conf)
+	defer sA.Shutdown()
+	errChan := make(chan struct{})
+	defer close(errChan)
+
+	ukp, _ := nkeys.CreateUser()
+	seed, _ := ukp.Seed()
+	upub, _ := ukp.PublicKey()
+	uclaim := newJWTTestUserClaims()
+	uclaim.Name = "myname"
+	uclaim.Subject = upub
+	uclaim.SetScoped(true)
+	uclaim.IssuerAccount = aPub
+
+	ujwt, err := uclaim.Encode(aSignScopedKp)
+	require_NoError(t, err)
+	creds := genCredsFile(t, ujwt, seed)
+
+	defer removeFile(t, creds)
+
+	t.Run("pass", func(t *testing.T) {
+		c := natsConnect(t, sA.ClientURL(), nats.UserCredentials(creds))
+		defer c.Close()
+		sub, err := c.SubscribeSync("foo.myname")
+		require_NoError(t, err)
+		require_NoError(t, c.Flush())
+		require_NoError(t, c.Publish("foo.myname", nil))
+		_, err = sub.NextMsg(time.Second)
+		require_NoError(t, err)
+	})
+	t.Run("fail", func(t *testing.T) {
+		c := natsConnect(t, sA.ClientURL(), nats.UserCredentials(creds),
+			nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) {
+				if strings.Contains(err.Error(), `nats: Permissions Violation for Publish to "foo.othername"`) {
+					errChan <- struct{}{}
+				}
+			}))
+		defer c.Close()
+		require_NoError(t, c.Publish("foo.othername", nil))
+		select {
+		case <-errChan:
+		case <-time.After(time.Second * 2):
+			require_True(t, false)
+		}
+	})
+}
+
 func TestJWTNoOperatorMode(t *testing.T) {
 	for _, login := range []bool{true, false} {
 		t.Run("", func(t *testing.T) {