Repository: nats-io/nats-server (2025-07-22T10:47:05Z)

Issue:
Response to `SYS.REQ.USER.INFO` is missing 'permissions' field when request is serverd by the server that did not authenticate the user
### Observed behavior

Responses to `SYS.REQ.USER.INFO` sometimes would not have 'permissions' at all.
```
nats request -s nats://localhost:4222  --user alex --password pass  '$SYS.REQ.USER.INFO' ""  --raw 
{"server":{"name":"s2","host":"0.0.0.0","id":"NBULZKJORPE7OAR6CIGFFZJ2ZEOQYVGIMJFXSQGFQ6D5CIDD5SRTQ5RS","cluster":"proc_compose","ver":"2.12.0-dev","jetstream":false,"flags":0,"seq":874,"time":"2025-07-21T21:52:04.981771334Z"},"data":{"user":"alex","account":"ACC"}}
```

This happens when we got response not from the server that we are connected to.

I believe this happens because permissions that we return 
https://github.com/nats-io/nats-server/blob/5a574da4ec772c18502ed46ca73e148c26c7bc7c/server/events.go#L1501
https://github.com/nats-io/nats-server/blob/5a574da4ec772c18502ed46ca73e148c26c7bc7c/server/client.go#L1069

Are only set inside `processClientOrLeafAuthentication`: 
https://github.com/nats-io/nats-server/blob/5a574da4ec772c18502ed46ca73e148c26c7bc7c/server/auth.go#L1083
https://github.com/nats-io/nats-server/blob/5a574da4ec772c18502ed46ca73e148c26c7bc7c/server/client.go#L932-L938

And so when we get response from the server that did not Authenticate us, the permissions is nil, and so it is not present in the json response.

### Expected behavior

'permissions' is always present in the response, no matter which server served the request.

### Server and client version

main (2.11.6)

### Host environment

_No response_

### Steps to reproduce

I added a quick and dirty test to demonstrate:
```
git switch alex/test_SYS.REQ.USER.INFO
```

```
# go test -v  -timeout 30s  -run ^TestClusterClientUserInfoReq$ github.com/nats-io/nats-server/v2/server -count=1
```
The test will run until we get a response with missing permissions.


Discussion:

Patch:
diff --git a/server/events.go b/server/events.go
index c34420135b6..0016de48961 100644
--- a/server/events.go
+++ b/server/events.go
@@ -1404,10 +1404,9 @@ func (s *Server) initEventTracking() {
 		}
 	}
 
-	// User info.
-	// TODO(dlc) - Can be internal and not forwarded since bound server for the client connection
-	// is only one that will answer. This breaks tests since we still forward on remote server connect.
-	if _, err := s.sysSubscribe(fmt.Sprintf(userDirectReqSubj, "*"), s.userInfoReq); err != nil {
+	// User info. Do not propagate interest so that we know the local server to the connection
+	// is the only one that will answer the requests.
+	if _, err := s.sysSubscribeInternal(fmt.Sprintf(userDirectReqSubj, "*"), s.userInfoReq); err != nil {
 		s.Errorf("Error setting up internal tracking: %v", err)
 		return
 	}


Test patch:
diff --git a/server/jwt_test.go b/server/jwt_test.go
index 043a113e5be..92e3450c33d 100644
--- a/server/jwt_test.go
+++ b/server/jwt_test.go
@@ -7366,3 +7366,60 @@ func TestJWTJetStreamClientsExcludedForMaxConnsUpdate(t *testing.T) {
 	_, err = js.Publish("foo", nil)
 	require_NoError(t, err)
 }
+
+func TestJWTClusterUserInfoContainsPermissions(t *testing.T) {
+	tmpl := `
+			listen: 127.0.0.1:-1
+			server_name: %s
+			jetstream: {max_mem_store: 256MB, max_file_store: 2GB, store_dir: '%s'}
+			cluster {
+				name: %s
+				listen: 127.0.0.1:%d
+				routes = [%s]
+			}
+	`
+	opFrag := `
+			operator: %s
+			system_account: %s
+			resolver: { type: MEM }
+			resolver_preload = {
+				%s : %s
+				%s : %s
+			}
+		`
+
+	_, syspub := createKey(t)
+	sysJwt := encodeClaim(t, jwt.NewAccountClaims(syspub), syspub)
+
+	accKp, aExpPub := createKey(t)
+	accClaim := jwt.NewAccountClaims(aExpPub)
+	accClaim.DefaultPermissions.Sub = jwt.Permission{
+		Deny: []string{"foo"},
+	}
+	accJwt := encodeClaim(t, accClaim, aExpPub)
+	accCreds := newUser(t, accKp)
+
+	template := tmpl + fmt.Sprintf(opFrag, ojwt, syspub, syspub, sysJwt, aExpPub, accJwt)
+	c := createJetStreamClusterWithTemplate(t, template, "R3S", 3)
+	defer c.shutdown()
+
+	// Since it's a bit of a race whether the local server responds via the
+	// service import before a remote server does, we need to keep trying.
+	// In 1000 attempts it is quite easy to reproduce the problem.
+	test := func() {
+		nc, _ := jsClientConnect(t, c.randomServer(), nats.UserCredentials(accCreds))
+		defer nc.Close()
+
+		resp, err := nc.Request(userDirectInfoSubj, nil, time.Second)
+		require_NoError(t, err)
+
+		response := ServerAPIResponse{Data: &UserInfo{}}
+		require_NoError(t, json.Unmarshal(resp.Data, &response))
+
+		userInfo := response.Data.(*UserInfo)
+		require_NotNil(t, userInfo.Permissions)
+	}
+	for range 1000 {
+		test()
+	}
+}