Compare commits

...

3 Commits

Author SHA1 Message Date
f41gh7
b8cdc0e5f6 add vm_default_access_claim 2026-06-25 17:46:56 +02:00
Zakhar Bessarab
c35781396b app/vmauth: allow skipping vm_access claim validation
Allow skipping "vm_access" claim validation in order to use claims match based routing. Previously, that required to
modify the token and add an artificial "vm_access: {}" value which is inconvinient.
2026-06-25 16:36:24 +02:00
Fred Navruzov
50a827256a docs/vmanomaly: fill in missing args and links (post v1.29.7 update) (#11165)
Addition of missing links/args and slight refactor of changelog notes
for clarity (post v1.29.7 update)

Follow-up on e30e8be1f4
2026-06-25 10:27:18 +03:00
9 changed files with 171 additions and 63 deletions

View File

@@ -140,6 +140,18 @@ users:
- "ProjectID: {{.MetricsProjectID}}"
url_prefix: "http://vminsert:8480/insert/prometheus"
# JWT-based routing that relies solely on custom claims.
# The `vm_access` claim is missing, default value will be used.
# e.g. {"role": "admin"}.
- name: jwt-custom-claims
jwt:
skip_verify: true
vm_default_access_claim:
metrics_account_id: 1
match_claims:
role: admin
url_prefix: "http://vmselect-admin:8481/select/0/prometheus"
# Requests without Authorization header are proxied according to `unauthorized_user` section.
# Requests are proxied in round-robin fashion between `url_prefix` backends.
# The deny_partial_response query arg is added to all the proxied requests.

View File

@@ -65,6 +65,8 @@ type JWTConfig struct {
MatchClaims map[string]string `yaml:"match_claims,omitempty"`
parsedMatchClaims []*jwt.Claim
DefaultVMAccessClaim *jwt.VMAccessClaim `yaml:"default_vm_access_claim,omitempty"`
// verifierPool is used to verify JWT tokens.
// It is initialized from PublicKeys and/or PublicKeyFiles.
// In this case, it is initialized once at config reload and never updated until next reload
@@ -432,7 +434,6 @@ func validateJWTPlaceholdersForURL(up *URLPrefix, isAllowed bool) error {
}
if strings.Contains(p, placeholderPrefix) {
return fmt.Errorf("invalid placeholder found in URL request path: %q, supported values are: %s", bu.Path, strings.Join(allPlaceholders, ", "))
}
}
for param, values := range bu.Query() {
@@ -487,7 +488,6 @@ func hasAnyPlaceholders(u *url.URL) bool {
return true
}
}
}
return false
}

View File

@@ -190,6 +190,10 @@ func requestHandler(w http.ResponseWriter, r *http.Request) bool {
if tkn == nil {
logger.Panicf("BUG: unexpected nil jwt token for user %q", ui.name())
}
if !tkn.HasVMAccessClaim() && ui.JWT.DefaultVMAccessClaim == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return true
}
defer putToken(tkn)
processUserRequest(w, r, ui, tkn)
return true
@@ -424,8 +428,12 @@ func processRequest(w http.ResponseWriter, r *http.Request, ui *UserInfo, tkn *j
}
targetURL := bu.url
if tkn != nil {
vmac := tkn.VMAccess()
if !tkn.HasVMAccessClaim() {
vmac = ui.JWT.DefaultVMAccessClaim
}
// for security reasons allow templating only for configured url values and headers
targetURL, hc = replaceJWTPlaceholders(bu, hc, tkn.VMAccess())
targetURL, hc = replaceJWTPlaceholders(bu, hc, vmac)
}
if isDefault {
// Don't change path and add request_path query param for default route.

View File

@@ -739,6 +739,12 @@ users:
"vm_access": map[string]any{},
}, false)
// token without vm_access claim, but with a custom claim usable for routing
roleToken := genToken(t, map[string]any{
"exp": time.Now().Add(10 * time.Minute).Unix(),
"role": "admin",
}, true)
fullToken := genToken(t, map[string]any{
"exp": time.Now().Add(10 * time.Minute).Unix(),
"vm_access": map[string]any{
@@ -779,6 +785,26 @@ statusCode=401
Unauthorized`
f(simpleCfgStr, request, responseExpected)
// token without vm_access claim is accepted when it
request = httptest.NewRequest(`GET`, "http://some-host.com/abc", nil)
request.Header.Set(`Authorization`, `Bearer `+roleToken)
responseExpected = `
statusCode=200
path: /foo/abc
query:
headers:`
f(fmt.Sprintf(`
users:
- jwt:
public_keys:
- %q
default_vm_access_claim:
metrics_account_id: 10
metrics_project_id: 10
match_claims:
role: admin
url_prefix: {BACKEND}/foo`, string(publicKeyPEM)), request, responseExpected)
// expired token
request = httptest.NewRequest(`GET`, "http://some-host.com/abc", nil)
request.Header.Set(`Authorization`, `Bearer `+expiredToken)

View File

@@ -17,11 +17,11 @@ Please find the changelog for VictoriaMetrics Anomaly Detection below.
## v1.29.7
Released: 2026-06-25
- UI: updated [vmanomaly UI](https://docs.victoriametrics.com/anomaly-detection/ui/) from [v1.7.1](https://docs.victoriametrics.com/anomaly-detection/ui/#v171) to [v1.7.2](https://docs.victoriametrics.com/anomaly-detection/ui/#v172), see respective [release notes](https://docs.victoriametrics.com/anomaly-detection/ui/#v172) for details.
- UI: updated [vmanomaly UI](https://docs.victoriametrics.com/anomaly-detection/ui/) from [v1.7.1](https://docs.victoriametrics.com/anomaly-detection/ui/#v171) to [v1.7.2](https://docs.victoriametrics.com/anomaly-detection/ui/#v172), see respective [release notes](https://docs.victoriametrics.com/anomaly-detection/ui/#v172) for details. Notable mentions include `api/v1/server/model` endpoint for accessing production models config and queries from UI, manually or through [AI assistant](https://docs.victoriametrics.com/anomaly-detection/ui/#ai-assistance).
- IMPROVEMENT: Increased high-cardinality inference scaling by optionally scattering periodic infer jobs to reduce contention on shared resources (e.g. datasource, CPU, RAM) when `settings.n_workers > 1` and `scheduler.infer_every` is smaller than the total time to fetch and process all queries. This is controlled by new `scatter_infer_jobs` boolean argument of [Periodic Scheduler](https://docs.victoriametrics.com/anomaly-detection/components/scheduler/#parameters-1) (default: `false`).
- IMPROVEMENT: Optimized internal batching for reader post-fetch series processing, exposing reader processing queue depth, and clarifying inference skip logs after data fetch timeouts.
- IMPROVEMENT: Optimized internal batching for reader post-fetch series processing, exposing reader processing queue depth (`vmanomaly_reader_processing_tasks_queued` [metric](https://docs.victoriametrics.com/anomaly-detection/components/monitoring/#reader-behaviour-metrics)), and clarifying inference skip logs after data fetch timeouts. See `series_processing_batch_size` argument of [VmReader](https://docs.victoriametrics.com/anomaly-detection/components/reader/#vm-reader) and [VLogsReader](https://docs.victoriametrics.com/anomaly-detection/components/reader/#victorialogs-reader) for details.
- IMPROVEMENT: Refined `VmReader` and `VLogsReader` logging after datasource request failures by suppressing the follow-up generic "No data" or "No unseen data" warning for failed fetches. Failed requests now keep the original datasource error while empty successful responses still emit the no-data warning.

View File

@@ -893,6 +893,19 @@ If a path to a CA bundle file (like `ca.crt`), it will verify the certificate us
(Optional) Password for authentication. If set, it will be used to authenticate the request.
</td>
</tr>
<tr>
<td>
<span style="white-space: nowrap;">`series_processing_batch_size`</span>
</td>
<td>
`8`
</td>
<td>
Optional argument {{% available_from "v1.29.7" anomaly %}}, allows specifying the number of time series to process together while preparing data for fit or infer stages. Defaults to `8`. Suggested values are 4-16 for high-cardinality queries.
</td>
</tr>
</tbody>
</table>
@@ -911,6 +924,7 @@ reader:
# tenant_id: '0:0' # for cluster version only
sampling_period: '1m'
max_points_per_query: 10000
series_processing_batch_size: 8
data_range: [0, 'inf'] # reader-level
offset: '0s' # reader-level
timeout: '30s'

View File

@@ -270,7 +270,7 @@ users:
url_prefix: "http://victoria-metrics:8428/"
```
JWT tokens must contain a `"vm_access": {}` claim, more on that in [JWT claim-based request templating](https://docs.victoriametrics.com/victoriametrics/vmauth/#jwt-claim-based-request-templating)
The `vm_access` claim is optional starting from {{% available_from "#" %}}: when present it is used for [request templating](https://docs.victoriametrics.com/victoriametrics/vmauth/#jwt-claim-based-request-templating), and when absent the default tenant `0:0` is assumed for any `vm_access`-based placeholders. Routing can rely solely on other token claims via [JWT claim matching](https://docs.victoriametrics.com/victoriametrics/vmauth/#jwt-claim-matching).
For testing, skip signature verification with `skip_verify: true` (not recommended for production).
@@ -520,7 +520,8 @@ for dynamic URL rewriting based on `vm_access` claim fields.
`vmauth` can dynamically rewrite{{% available_from "v1.137.0" %}} upstream URLs and request headers using values from the JWT `vm_access` claim.
This enables routing different users to different backends or tenants based solely on the JWT token,
without maintaining separate user configs per tenant.
without maintaining separate user configs per tenant. In addition `vm_access` claim could be defined at `jwt` section with `default_vm_access_claim` {{% available_from "#" %}}.
In this case, if JWT token doesn't have `vm_access` claim defined, value from `default_vm_access_claim` will be used for templaing.
Example: minimal valid JWT. If vm_access is empty, tenant `0:0` is assumed and no additional filters are applied.
```json
@@ -575,6 +576,28 @@ Placeholders are supported in the following locations:
Placeholders are **not** supported in response headers.
They are also only valid for JWT-authenticated users — using them in configs for `username`/`password` or `bearer_token` users causes a configuration error.
Example: default `vm_access` claim:
```yaml
users:
- jwt:
default_vm_access_claim:
metrics_account_id: 10
metrics_project_id: 10
metrics_extra_filters:
- '{instance="sandbox"}'
metrics_extra_labels:
- team=dev
- env=dev
public_keys:
- |
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----
url_prefix: "http://vminsert:8480/insert/{{.MetricsAccountID}}:{{.MetricsProjectID}}/prometheus/?extra_filters={{.MetricsExtraFilters}}&extra_label={{.MetricsExtraLabels}}"
```
Example: route requests to the VictoriaMetrics single-node:
```yaml

View File

@@ -105,6 +105,10 @@ type body struct {
Scope string `json:"scope,omitempty"`
vmAccessClaim VMAccessClaim
// hasVMAccess is set to true when the token body contains a `vm_access` claim.
// Presence enforcement is left to the caller via Token.HasVMAccess.
hasVMAccess bool
buf []byte
p *fastjson.Parser
@@ -121,7 +125,6 @@ type body struct {
}
func (b *body) parse(src string) error {
var err error
b.buf, err = decodeB64(b.buf[:0], src)
if err != nil {
@@ -132,6 +135,9 @@ func (b *body) parse(src string) error {
if err != nil {
return err
}
if jv.Type() != fastjson.TypeObject {
return fmt.Errorf("unexpected non json object; type: %q", jv.Type())
}
if expObject := jv.Get("exp"); expObject != nil {
b.Exp, err = expObject.Int64()
if err != nil {
@@ -153,30 +159,31 @@ func (b *body) parse(src string) error {
}
vaObject := jv.Get("vm_access")
if vaObject == nil {
return ErrVMAccessFieldMissing
}
// some IDPs encode custom claims as a string
// try parsing as an object and fallback to a string
switch vaObject.Type() {
case fastjson.TypeObject:
if err := b.vmAccessClaim.parseFrom(vaObject); err != nil {
return err
}
case fastjson.TypeString:
b.claimsParser = parserPool.Get()
va, err := b.claimsParser.ParseBytes(vaObject.GetStringBytes())
if err != nil {
return fmt.Errorf("cannot parse `vm_access` string json: %w", err)
}
if err := b.vmAccessClaim.parseFrom(va); err != nil {
return fmt.Errorf("cannot parse `vm_access` values from string json: %w", err)
}
b.vmAccessClaimObject = va
case fastjson.TypeNull:
return ErrVMAccessFieldMissing
switch {
case vaObject == nil || vaObject.Type() == fastjson.TypeNull:
b.hasVMAccess = false
default:
return fmt.Errorf("unexpected type for `vm_access` field; got: %q, want object {}", vaObject.Type())
// some IDPs encode custom claims as a string
// try parsing as an object and fallback to a string
switch vaObject.Type() {
case fastjson.TypeObject:
if err := b.vmAccessClaim.parseFrom(vaObject); err != nil {
return err
}
case fastjson.TypeString:
b.claimsParser = parserPool.Get()
va, err := b.claimsParser.ParseBytes(vaObject.GetStringBytes())
if err != nil {
return fmt.Errorf("cannot parse `vm_access` string json: %w", err)
}
if err := b.vmAccessClaim.parseFrom(va); err != nil {
return fmt.Errorf("cannot parse `vm_access` values from string json: %w", err)
}
b.vmAccessClaimObject = va
default:
return fmt.Errorf("unexpected type for `vm_access` field; got: %q, want object {}", vaObject.Type())
}
b.hasVMAccess = true
}
b.Jti = bytesutil.ToUnsafeString(jv.GetStringBytes("jti"))
@@ -218,6 +225,7 @@ func (b *body) reset() {
b.buf = b.buf[:0]
b.allClaims = nil
b.vmAccessClaim.reset()
b.hasVMAccess = false
if b.p != nil {
parserPool.Put(b.p)
b.p = nil
@@ -229,11 +237,9 @@ func (b *body) reset() {
if b.vmAccessClaimObject != nil {
b.vmAccessClaimObject = nil
}
}
// Parse parses JWT token from given source string
//
// Token field is valid until src is reachable
func (t *Token) Parse(src string, enforceAuthPrefix bool) error {
if enforceAuthPrefix && (len(src) < len(prefix) || !strings.EqualFold(src[:len(prefix)], prefix)) {
@@ -268,6 +274,11 @@ func (t *Token) Parse(src string, enforceAuthPrefix bool) error {
return nil
}
// HasVMAccessClaim reports whether the parsed token contains a `vm_access` claim.
func (t *Token) HasVMAccessClaim() bool {
return t.body.hasVMAccess
}
// Issuer returns `iss` claim value from token body
func (t *Token) Issuer() string {
return t.body.Iss
@@ -371,30 +382,30 @@ func (t *Token) Reset() {
// VMAccessClaim represent JWT claim object
type VMAccessClaim struct {
MetricsExtraFilters []string `json:"metrics_extra_filters,omitempty"`
MetricsExtraLabels []string `json:"metrics_extra_labels,omitempty"`
LogsExtraFilters []string `json:"logs_extra_filters,omitempty"`
LogsExtraStreamFilters []string `json:"logs_extra_stream_filters,omitempty"`
MetricsExtraFilters []string `json:"metrics_extra_filters,omitempty" yaml:"metrics_extra_filters,omitempty"`
MetricsExtraLabels []string `json:"metrics_extra_labels,omitempty" yaml:"metrics_extra_labels,omitempty"`
LogsExtraFilters []string `json:"logs_extra_filters,omitempty" yaml:"logs_extra_filters,omitempty"`
LogsExtraStreamFilters []string `json:"logs_extra_stream_filters,omitempty" yaml:"logs_extra_stream_filters,omitempty"`
MetricsAccountID uint32 `json:"metrics_account_id,omitempty"`
MetricsProjectID uint32 `json:"metrics_project_id,omitempty"`
MetricsAccountID uint32 `json:"metrics_account_id,omitempty" yaml:"metrics_account_id,omitempty"`
MetricsProjectID uint32 `json:"metrics_project_id,omitempty" yaml:"metrics_project_id,omitempty"`
LogsAccountID uint32 `json:"logs_account_id,omitempty"`
LogsProjectID uint32 `json:"logs_project_id,omitempty"`
LogsAccountID uint32 `json:"logs_account_id,omitempty" yaml:"logs_account_id,omitempty"`
LogsProjectID uint32 `json:"logs_project_id,omitempty" yaml:"logs_project_id,omitempty"`
// Properties below are deprecated and retained only for compatibility with vmgateway, which is itself deprecated.
// promql filters applied to each select query
// Deprecated
ExtraFilters []string `json:"extra_filters,omitempty"`
ExtraFilters []string `json:"extra_filters,omitempty" yaml:"-"`
// Deprecated
Tenant TenantID `json:"tenant_id"`
Tenant TenantID `json:"tenant_id" yaml:"-"`
// role can be denied as 1 = read, 2 = write, 3 = read and write
// 0 = unconfigured - read and write
// Deprecated
Mode int `json:"mode,omitempty"`
Mode int `json:"mode,omitempty" yaml:"-"`
// Deprecated
Labels []string `json:"extra_labels,omitempty"`
Labels []string `json:"extra_labels,omitempty" yaml:"-"`
// labelsBuf holds allocated memory for Labels
// Deprecated
labelsBuf []byte
@@ -425,7 +436,6 @@ func (vac *VMAccessClaim) reset() {
}
func (vac *VMAccessClaim) parseFrom(jv *fastjson.Value) error {
if err := vac.Tenant.parseFrom(jv); err != nil {
return err
}
@@ -569,6 +579,9 @@ func NewToken(auth string, enforceAuthPrefix bool) (*Token, error) {
if err := t.parse(jwt[0], jwt[1], jwt[2]); err != nil {
return nil, err
}
if !t.body.hasVMAccess {
return nil, ErrVMAccessFieldMissing
}
return &t, nil
}

View File

@@ -168,17 +168,10 @@ func TestParseJWTBody_Failure(t *testing.T) {
true,
)
// invalid body type json
// non-object body type
f(
`[]`,
"missing `vm_access` claim",
true,
)
// missing vm_access claim
f(
`{}`,
"missing `vm_access` claim",
`unexpected non json object; type: "array"`,
true,
)
@@ -189,13 +182,6 @@ func TestParseJWTBody_Failure(t *testing.T) {
true,
)
// vm_access claim null
f(
`{"vm_access": null}`,
"missing `vm_access` claim",
true,
)
// invalid vm_access: account_id type mismatch
f(
`{"vm_access": {"tenant_id": {"account_id": "1", "project_id": 5}}}`,
@@ -555,6 +541,33 @@ func TestParseJWTBody_Success(t *testing.T) {
)
}
func TestParseJWTBody_VMAccessPresence(t *testing.T) {
f := func(data string, wantHasVMAccess bool) {
t.Helper()
encodedLen := base64.RawURLEncoding.EncodedLen(len(data))
encoded := make([]byte, encodedLen)
base64.RawURLEncoding.Encode(encoded, []byte(data))
var b body
if err := b.parse(string(encoded)); err != nil {
t.Fatalf("unexpected error: %s", err)
}
if b.hasVMAccess != wantHasVMAccess {
t.Fatalf("unexpected hasVMAccess; got %v; want %v", b.hasVMAccess, wantHasVMAccess)
}
}
// vm_access claim is present
f(`{"vm_access": {}}`, true)
f(`{"vm_access": {"metrics_account_id": 1}}`, true)
// vm_access claim is absent or null - parsing must succeed with hasVMAccess=false
f(`{}`, false)
f(`{"vm_access": null}`, false)
f(`{"role": "admin"}`, false)
}
func TestNewTokenFromRequest_Failure(t *testing.T) {
f := func(r *http.Request) {
t.Helper()
@@ -866,7 +879,6 @@ func TestNewTokenFromRequest_Success(t *testing.T) {
}
func TestTokenMatchClaims(t *testing.T) {
/*
{
"iss": "https://login.microsoftonline.com/-6691-4868-a77b-1b0f9bbe5f43/v2.0",