From be5ae9b95c3d45c8d2dd51b7a551e5f3f3b39266 Mon Sep 17 00:00:00 2001 From: andriibeee <154226341+andriibeee@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:23:43 +0200 Subject: [PATCH] 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 --- docs/victoriametrics/changelog/CHANGELOG.md | 1 + docs/victoriametrics/vmauth.md | 32 +++++++++++-- lib/jwt/jwt.go | 32 +++++++++++-- lib/jwt/jwt_test.go | 52 +++++++++++++++++++++ 4 files changed, 110 insertions(+), 7 deletions(-) diff --git a/docs/victoriametrics/changelog/CHANGELOG.md b/docs/victoriametrics/changelog/CHANGELOG.md index 923639b028..eb66a91bd7 100644 --- a/docs/victoriametrics/changelog/CHANGELOG.md +++ b/docs/victoriametrics/changelog/CHANGELOG.md @@ -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) diff --git a/docs/victoriametrics/vmauth.md b/docs/victoriametrics/vmauth.md index e6972c762e..4ee6e05b54 100644 --- a/docs/victoriametrics/vmauth.md +++ b/docs/victoriametrics/vmauth.md @@ -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: diff --git a/lib/jwt/jwt.go b/lib/jwt/jwt.go index e6c0ad9f37..270dc778dd 100644 --- a/lib/jwt/jwt.go +++ b/lib/jwt/jwt.go @@ -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 diff --git a/lib/jwt/jwt_test.go b/lib/jwt/jwt_test.go index e5179e6d83..b96a41f71e 100644 --- a/lib/jwt/jwt_test.go +++ b/lib/jwt/jwt_test.go @@ -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) }