lib/jwt: support array claim values in match_claims

This commit allows to perform JWT claim matching over 1 dimension arrays. It could
be useful from practical standpoint. Because permissions are usually assigned as a list of values.

  For example, the following config allows admin access over list of assigned roles for user:

```yaml
 match_claims:
   access.roles: "admin"
```

JWT token:
```json
 {
  "access": {
    "roles": [
      "read",
      "write",
      "admin"
   ]
 }
}
```

Fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10647
This commit is contained in:
andriibeee
2026-03-26 11:23:43 +02:00
committed by GitHub
parent 60aef0510f
commit be5ae9b95c
4 changed files with 110 additions and 7 deletions

View File

@@ -31,6 +31,7 @@ See also [LTS releases](https://docs.victoriametrics.com/victoriametrics/lts-rel
* FEATURE: [vmalert](https://docs.victoriametrics.com/victoriametrics/vmalert/): add `search` parameter and pagination support in `/api/v1/rules` API. See [#10046](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/10046).
* FEATURE: [vmui](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#vmui): add default pagination to improve the Alerting Rules page experience when vmalert loads thousands of rules. See [#10046](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/10046).
* FEATURE: all VictoriaMetrics components: log a warning when an IPv6 listen address (e.g. `[::]:6969`) is specified but `-enableTCP6` is not set. Previously, the server silently listened on IPv4 only. See [#6858](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6858). Thanks to @andriibeee for the contribution.
* FEATURE: [vmauth](https://docs.victoriametrics.com/victoriametrics/vmauth/): extend JWT [claim matching](https://docs.victoriametrics.com/victoriametrics/vmauth/#jwt-claim-matching) with array claim values support. See [#10647](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10647). Thanks to @andriibeee for the contribution.
* FEATURE: [vmagent](https://docs.victoriametrics.com/victoriametrics/vmagent/): allow specifying `basic_auth` in scrape configs without `username`. Previously this resulted in a config error. Now a warning is logged instead. See [#6956](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6956). Thanks to @andriibeee for the contribution.
* BUGFIX: `vmselect` in [VictoriaMetrics cluster](https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/): retry RPC by dialing a new connection instead of reusing a pooled one when the previous attempt fails with `io.EOF`, `broken pipe` or `reset by peer`. This reduces query failures caused by stale connections to restarted vmstorage nodes. See [#10314](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10314)

View File

@@ -326,10 +326,9 @@ signed with the configured public key.
Claim names support dot-notation for traversal of nested JSON objects
(a simplified JSONPath-style approach), for example `vm_access.metrics_account_id` matches `{"vm_access": {"metrics_account_id": 1}}` and
`security.permissions.0.read` matches `{"security": {"permissions": [{"read": 1}]}}.
Claim names must point to a **leaf value**. The only supported leaf values are string, integer, float and boolean. Any other leaf type
is treated as not matched.
All configured claims must match exactly.
Claim match values use regular expression syntax and must fully match the claim value.
Claim names must point to a **leaf value** or an **array**. The supported leaf types are string, integer, float and boolean.
If the claim value is an array, each scalar element is compared against the match value - the claim matches if any element matches. Objects and nested arrays inside the array are skipped.
All configured claims must match and the values use regular expression syntax.
For example, the following config routes requests based on the `role` claim in the JWT token:
@@ -382,6 +381,31 @@ users:
url_prefix: "http://victoria-metrics-tenant-2:8428/"
```
The following config matches against array claim values.
The first user matches a token with claim `{"roles": ["admin"]}`, while the second matches a token with claim `{"roles": ["read"]}` or `{"roles": ["write"]}`.
```yaml
users:
- jwt:
public_keys:
- |
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----
match_claims:
roles: admin
url_prefix: "http://victoria-metrics-admin:8428/"
- jwt:
public_keys:
- |
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----
match_claims:
roles: "^(read|write)$"
url_prefix: "http://victoria-metrics-readonly:8428/"
```
The following config matches any valid token (no claim filtering),
equivalent to the behavior when `match_claims` is omitted:

View File

@@ -310,11 +310,16 @@ func (t *Token) matchClaim(c *Claim) bool {
} else {
gotV = t.body.allClaims.Get(keys...)
}
if gotV == nil || gotV.Type() == fastjson.TypeArray || gotV.Type() == fastjson.TypeObject {
// key not found or has complex structure
if gotV == nil {
// fast path
return false
}
if gotV.Type() == fastjson.TypeString {
switch gotV.Type() {
case fastjson.TypeObject, fastjson.TypeNull:
return false
case fastjson.TypeArray:
return matchClaimArray(c, gotV.GetArray())
case fastjson.TypeString:
return c.valueRe.Match(gotV.GetStringBytes())
}
bb := claimValuePool.Get()
@@ -326,6 +331,27 @@ func (t *Token) matchClaim(c *Claim) bool {
return match
}
func matchClaimArray(c *Claim, elems []*fastjson.Value) bool {
bb := claimValuePool.Get()
defer claimValuePool.Put(bb)
for _, elem := range elems {
switch elem.Type() {
case fastjson.TypeString:
if c.valueRe.Match(elem.GetStringBytes()) {
return true
}
case fastjson.TypeObject, fastjson.TypeArray, fastjson.TypeNull:
default:
bb.B = elem.MarshalTo(bb.B[:0])
if c.valueRe.Match(bb.B) {
return true
}
}
}
return false
}
var claimValuePool bytesutil.ByteBufferPool
// VMAccess return a reference to the VMAccessClaim

View File

@@ -948,6 +948,12 @@ func TestTokenMatchClaims(t *testing.T) {
}
f(&tokenWithStrFields, claims, false)
// string array element match via an index path
f(&tokenWithStrFields, map[string]string{"security.nested_array.0.values": "read"}, true)
// checking array of hashmaps against a string
f(&tokenWithStrFields, map[string]string{"security.permissions": "read"}, false)
// key not found
claims = map[string]string{
"name": "Zakhar",
@@ -1025,4 +1031,50 @@ func TestTokenMatchClaims(t *testing.T) {
}
f(&tokenObjectFields, claims, false)
/*
{
"name": "Test",
"roles": ["read", "write", "admin"],
"group_ids": [100, 200, 300],
"mixed": ["foo", 42, true, null, {"nested": "obj"}, ["inner"]],
"access": {"permissions": ["vm_read", "vm_write"]},
"empty_arr": [],
"vm_access": {"tenant_id": {"project_id": 1, "account_id": 1}}
}
*/
tokenArrayStr := "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImZmZi1sQjl3In0.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzI1NjI1MzMyLCJleHAiOjE3MjU2MjkyMzIsIm5hbWUiOiJUZXN0Iiwicm9sZXMiOlsicmVhZCIsIndyaXRlIiwiYWRtaW4iXSwiZ3JvdXBfaWRzIjpbMTAwLDIwMCwzMDBdLCJtaXhlZCI6WyJmb28iLDQyLHRydWUsbnVsbCx7Im5lc3RlZCI6Im9iaiJ9LFsiaW5uZXIiXV0sImFjY2VzcyI6eyJwZXJtaXNzaW9ucyI6WyJ2bV9yZWFkIiwidm1fd3JpdGUiXX0sImVtcHR5X2FyciI6W10sInZtX2FjY2VzcyI6eyJ0ZW5hbnRfaWQiOnsicHJvamVjdF9pZCI6MSwiYWNjb3VudF9pZCI6MX19fQ.hhI4capDL1wIfEiTP-biVzszFj55yBR_zRUcEisommXSs-whv1XvCMgUc_KGHj0pzO-YpKVooitfgA6PjNp82xFvCvlEsNI96kN-YKyn4wQShzswXJG_mQdYPhSPoD0UzdDXE6soNzgaMjGxpPA6sOhRGJtAKa0BR-eQgIS-8vcI5P4ymX-Geer1XjHM5I2rsPdKFxrwLec2l1qCTYqWuzS7gb3GH_19lBN13IJXdVmu8zXueVXOq_z9TgQVtQQtaWIW1-URmNk3tOvu78_lDjc1W0e7GtevOdkXl7a6NMtD-fl2Gh-S_shJHApqs93JIkdV6QABoH_Uvt9bTMFsmA"
var tokenArrayFields Token
if err := tokenArrayFields.Parse(tokenArrayStr, false); err != nil {
t.Fatalf("BUG: cannot parse JWT token: %s", err)
}
// string array
f(&tokenArrayFields, map[string]string{"roles": "^read$"}, true)
f(&tokenArrayFields, map[string]string{"roles": "^admin$"}, true)
f(&tokenArrayFields, map[string]string{"roles": "^nobody$"}, false)
f(&tokenArrayFields, map[string]string{"roles": "^(read|write)$"}, true)
f(&tokenArrayFields, map[string]string{"roles": "wr.*"}, true)
// numeric array
f(&tokenArrayFields, map[string]string{"group_ids": "^200$"}, true)
f(&tokenArrayFields, map[string]string{"group_ids": "^999$"}, false)
// nested array via a dot path
f(&tokenArrayFields, map[string]string{"access.permissions": "^vm_read$"}, true)
f(&tokenArrayFields, map[string]string{"access.permissions": "^vm_delete$"}, false)
// mixed array
// hashmaps and nested arrays are skipped
f(&tokenArrayFields, map[string]string{"mixed": "^foo$"}, true)
f(&tokenArrayFields, map[string]string{"mixed": "^42$"}, true)
f(&tokenArrayFields, map[string]string{"mixed": "^true$"}, true)
f(&tokenArrayFields, map[string]string{"mixed": "^obj$"}, false)
f(&tokenArrayFields, map[string]string{"mixed": "^inner$"}, false)
// empty array
f(&tokenArrayFields, map[string]string{"empty_arr": ".*"}, false)
// array claim combined with scalar claim
f(&tokenArrayFields, map[string]string{"name": "Test", "roles": "admin"}, true)
f(&tokenArrayFields, map[string]string{"name": "Test", "roles": "^nobody$"}, false)
}