Compare commits

...

6 Commits

Author SHA1 Message Date
cubic-dev-ai[bot]
5ecbcf1868 fix(jwt): use tolerant int64 deserialization for tenant ID fields with uint32 range check 2026-02-24 09:07:56 +00:00
f41gh7
a3782424db fixes typos
Signed-off-by: f41gh7 <nik@victoriametrics.com>
2026-02-24 10:01:00 +01:00
f41gh7
abbbdb0011 address review comments
* simplify placeholder logic with pre-defined data structure
* add validation helper functions
* consolidate JWT placeholders parsing logic
* slightly reduce memory allocations for query templating
* do not allow templating for client request url params
2026-02-24 09:46:36 +01:00
Max Kotliar
3643901848 Update app/vmauth/jwt.go
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Signed-off-by: Max Kotliar <kotlyar.maksim@gmail.com>
2026-02-19 18:32:22 +02:00
Max Kotliar
df17563a53 Update app/vmauth/jwt.go
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Signed-off-by: Max Kotliar <kotlyar.maksim@gmail.com>
2026-02-19 18:31:58 +02:00
Max Kotliar
7ffa23ce75 app/vmauth: implement upstream request templating based on JWT vm_access claim
Fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10492
2026-02-19 18:19:17 +02:00
8 changed files with 1140 additions and 62 deletions

View File

@@ -104,9 +104,10 @@ type UserInfo struct {
// HeadersConf represents config for request and response headers.
type HeadersConf struct {
RequestHeaders []*Header `yaml:"headers,omitempty"`
ResponseHeaders []*Header `yaml:"response_headers,omitempty"`
KeepOriginalHost *bool `yaml:"keep_original_host,omitempty"`
RequestHeaders []*Header `yaml:"headers,omitempty"`
ResponseHeaders []*Header `yaml:"response_headers,omitempty"`
KeepOriginalHost *bool `yaml:"keep_original_host,omitempty"`
hasAnyPlaceHolders bool
}
func (ui *UserInfo) beginConcurrencyLimit(ctx context.Context) error {
@@ -349,6 +350,7 @@ func (bus *backendURLs) add(u *url.URL) {
url: u,
healthCheckContext: bus.healthChecksContext,
healthCheckWG: &bus.healthChecksWG,
hasPlaceHolders: hasAnyPlaceholders(u),
})
}
@@ -366,6 +368,8 @@ type backendURL struct {
concurrentRequests atomic.Int32
url *url.URL
hasPlaceHolders bool
}
func (bu *backendURL) isBroken() bool {
@@ -903,6 +907,9 @@ func parseAuthConfig(data []byte) (*AuthConfig, error) {
if ui.Name != "" {
return nil, fmt.Errorf("field name can't be specified for unauthorized_user section")
}
if err := parseJWTPlaceholdersForUserInfo(ui, false); err != nil {
return nil, err
}
if err := ui.initURLs(); err != nil {
return nil, err
}
@@ -960,6 +967,10 @@ func parseAuthConfigUsers(ac *AuthConfig) (map[string]*UserInfo, error) {
at, ui.Username, ui.Name, uiOld.Username, uiOld.Name)
}
}
if err := parseJWTPlaceholdersForUserInfo(ui, false); err != nil {
return nil, err
}
if err := ui.initURLs(); err != nil {
return nil, err
}
@@ -1059,6 +1070,7 @@ func (ui *UserInfo) initURLs() error {
return err
}
}
for _, e := range ui.URLMaps {
if len(e.SrcPaths) == 0 && len(e.SrcHosts) == 0 && len(e.SrcQueryArgs) == 0 && len(e.SrcHeaders) == 0 {
return fmt.Errorf("missing `src_paths`, `src_hosts`, `src_query_args` and `src_headers` in `url_map`")

View File

@@ -276,6 +276,50 @@ users:
url_prefix: http://foo.bar
metric_labels:
not-prometheus-compatible: value
`)
// placeholder in url_prefix
f(`
users:
- username: foo
password: bar
url_prefix: 'http://ahost/{{a_placeholder}}/foobar'
`)
// placeholder in a header
f(`
users:
- username: foo
password: bar
headers:
- 'X-Foo: {{a_placeholder}}'
url_prefix: 'http://ahost'
`)
// placeholder in url_prefix
f(`
users:
- username: foo
password: bar
url_prefix: 'http://ahost/{{a_placeholder}}/foobar'
`)
// placeholder in a header in url_map
f(`
users:
- username: foo
password: bar
url_map:
- src_paths: ["/select/.*"]
headers:
- 'X-Foo: {{a_placeholder}}'
url_prefix: 'http://ahost'
`)
// placeholder in a header in url_map
f(`
users:
- username: foo
password: bar
url_map:
- src_paths: ["/select/.*"]
url_prefix: 'http://ahost/{{a_placeholder}}/foobar'
`)
}

View File

@@ -2,7 +2,9 @@ package main
import (
"fmt"
"net/url"
"os"
"slices"
"strings"
"time"
@@ -10,6 +12,35 @@ import (
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
)
const (
metricsTenantPlaceholder = `{{.MetricsTenant}}`
metricsExtraLabelsPlaceholder = `{{.MetricsExtraLabels}}`
metricsExtraFiltersPlaceholder = `{{.MetricsExtraFilters}}`
logsAccountIDPlaceholder = `{{.LogsAccountID}}`
logsProjectIDPlaceholder = `{{.LogsProjectID}}`
logsExtraFiltersPlaceholder = `{{.LogsExtraFilters}}`
logsExtraStreamFiltersPlaceholder = `{{.LogsExtraStreamFilters}}`
placeholderPrefix = `{{`
)
var allPlaceholders = []string{
metricsTenantPlaceholder,
metricsExtraLabelsPlaceholder,
metricsExtraFiltersPlaceholder,
logsAccountIDPlaceholder,
logsProjectIDPlaceholder,
logsExtraFiltersPlaceholder,
logsExtraStreamFiltersPlaceholder,
}
var urlPathPlaceHolders = []string{
metricsTenantPlaceholder,
logsAccountIDPlaceholder,
logsProjectIDPlaceholder,
}
type jwtCache struct {
// users contain UserInfo`s from AuthConfig with JWTConfig set
users []*UserInfo
@@ -68,6 +99,9 @@ func parseJWTUsers(ac *AuthConfig) ([]*UserInfo, error) {
jwtToken.verifierPool = vp
}
if err := parseJWTPlaceholdersForUserInfo(&ui, true); err != nil {
return nil, err
}
if err := ui.initURLs(); err != nil {
return nil, err
@@ -101,7 +135,7 @@ func parseJWTUsers(ac *AuthConfig) ([]*UserInfo, error) {
jui = append(jui, &ui)
}
// the limitation will be lifted once claim based matching will be implemented
// TODO: the limitation will be lifted once claim based matching will be implemented
if len(jui) > 1 {
return nil, fmt.Errorf("multiple users with JWT tokens are not supported; found %d users", len(jui))
}
@@ -109,10 +143,10 @@ func parseJWTUsers(ac *AuthConfig) ([]*UserInfo, error) {
return jui, nil
}
func getUserInfoByJWTToken(ats []string) *UserInfo {
func getUserInfoByJWTToken(ats []string) (*UserInfo, *jwt.Token) {
js := *jwtAuthCache.Load()
if len(js.users) == 0 {
return nil
return nil, nil
}
for _, at := range ats {
@@ -131,6 +165,8 @@ func getUserInfoByJWTToken(ats []string) *UserInfo {
}
if tkn.IsExpired(time.Now()) {
if *logInvalidAuthTokens {
// TODO: add more context:
// token claims with issuer
logger.Infof("jwt token is expired")
}
continue
@@ -138,7 +174,7 @@ func getUserInfoByJWTToken(ats []string) *UserInfo {
for _, ui := range js.users {
if ui.JWT.SkipVerify {
return ui
return ui, tkn
}
if err := ui.JWT.verifierPool.Verify(tkn); err != nil {
@@ -148,9 +184,190 @@ func getUserInfoByJWTToken(ats []string) *UserInfo {
continue
}
return ui
return ui, tkn
}
}
return nil, nil
}
func replaceJWTPlaceholders(bu *backendURL, hc HeadersConf, vma *jwt.VMAccessClaim) (*url.URL, HeadersConf) {
if !bu.hasPlaceHolders && !hc.hasAnyPlaceHolders {
return bu.url, hc
}
targetURL := bu.url
data := jwtClaimsData(vma)
if bu.hasPlaceHolders {
// template url params and request path
// make a copy of url
uCopy := *bu.url
for _, uph := range urlPathPlaceHolders {
replacement := data[uph]
uCopy.Path = strings.ReplaceAll(uCopy.Path, uph, replacement[0])
}
query := uCopy.Query()
var foundAnyQueryPlaceholder bool
var templatedValues []string
for param, values := range query {
templatedValues = templatedValues[:0]
// filter in-place values with placeholders
// and accumulate replacements
// it will change the order of param values
// but it's not guaranteed
// and will be changed in any way with multiple arg templates
var cnt int
for _, value := range values {
if dv, ok := data[value]; ok {
foundAnyQueryPlaceholder = true
templatedValues = append(templatedValues, dv...)
continue
}
values[cnt] = value
cnt++
}
values = values[:cnt]
values = append(values, templatedValues...)
query[param] = values
}
if foundAnyQueryPlaceholder {
uCopy.RawQuery = query.Encode()
}
targetURL = &uCopy
}
if hc.hasAnyPlaceHolders {
// make a copy of headers and update only values with placeholder
rhs := make([]*Header, 0, len(hc.RequestHeaders))
for _, rh := range hc.RequestHeaders {
if dv, ok := data[rh.Value]; ok {
rh := &Header{
Name: rh.Name,
Value: strings.Join(dv, ","),
}
rhs = append(rhs, rh)
continue
}
rhs = append(rhs, rh)
}
hc.RequestHeaders = rhs
}
return targetURL, hc
}
func jwtClaimsData(vma *jwt.VMAccessClaim) map[string][]string {
data := map[string][]string{
// TODO: optimize at parsing stage
metricsTenantPlaceholder: {fmt.Sprintf("%d:%d", vma.MetricsAccountID, vma.MetricsProjectID)},
metricsExtraLabelsPlaceholder: vma.MetricsExtraLabels,
metricsExtraFiltersPlaceholder: vma.MetricsExtraFilters,
// TODO: optimize at parsing stage
logsAccountIDPlaceholder: {fmt.Sprintf("%d", vma.LogsAccountID)},
logsProjectIDPlaceholder: {fmt.Sprintf("%d", vma.LogsProjectID)},
logsExtraFiltersPlaceholder: vma.LogsExtraFilters,
logsExtraStreamFiltersPlaceholder: vma.LogsExtraStreamFilters,
}
return data
}
func parseJWTPlaceholdersForUserInfo(ui *UserInfo, isAllowed bool) error {
if ui.URLPrefix != nil {
if err := validateJWTPlaceholdersForURL(ui.URLPrefix, isAllowed); err != nil {
return err
}
}
if err := parsePlaceholdersForHC(&ui.HeadersConf, isAllowed); err != nil {
return err
}
if ui.DefaultURL != nil {
if err := validateJWTPlaceholdersForURL(ui.DefaultURL, isAllowed); err != nil {
return fmt.Errorf("invalid `default_url` placeholders: %w", err)
}
}
for i := range ui.URLMaps {
e := &ui.URLMaps[i]
if e.URLPrefix != nil {
if err := validateJWTPlaceholdersForURL(e.URLPrefix, isAllowed); err != nil {
return fmt.Errorf("invalid `url_map` `url_prefix` placeholders: %w", err)
}
}
if err := parsePlaceholdersForHC(&e.HeadersConf, isAllowed); err != nil {
return fmt.Errorf("invalid `url_map` headers placeholders: %w", err)
}
}
return nil
}
func validateJWTPlaceholdersForURL(up *URLPrefix, isAllowed bool) error {
for _, bu := range up.busOriginal {
ok := strings.Contains(bu.Path, placeholderPrefix)
if ok && !isAllowed {
return fmt.Errorf("placeholder: %q is only allowed at JWT token context", bu.Path)
}
if ok {
p := bu.Path
for _, ph := range allPlaceholders {
p = strings.ReplaceAll(p, ph, ``)
}
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() {
for _, value := range values {
ok := strings.Contains(value, placeholderPrefix)
if ok && !isAllowed {
return fmt.Errorf("query param: %q with placeholder: %q is only allowed at JWT token context", param, value)
}
if ok {
// possible placeholder
if !slices.Contains(allPlaceholders, value) {
return fmt.Errorf("query param: %q has unsupported placeholder string: %q, supported values are: %s", param, value, strings.Join(allPlaceholders, ", "))
}
}
}
}
}
return nil
}
func parsePlaceholdersForHC(hc *HeadersConf, isAllowed bool) error {
for _, rhs := range hc.RequestHeaders {
ok := strings.Contains(rhs.Value, placeholderPrefix)
if ok && !isAllowed {
return fmt.Errorf("request header: %q placeholder: %q is only supported at JWT context", rhs.Name, rhs.Value)
}
if ok {
if !slices.Contains(allPlaceholders, rhs.Value) {
return fmt.Errorf("request header: %q has unsupported placeholder: %q, supported values are: %s", rhs.Name, rhs.Value, strings.Join(allPlaceholders, ", "))
}
hc.hasAnyPlaceHolders = true
}
}
for _, rhs := range hc.ResponseHeaders {
if strings.Contains(rhs.Value, placeholderPrefix) {
return fmt.Errorf("response header placeholders are not supported; found placeholder prefix at header: %q with value: %q", rhs.Name, rhs.Value)
}
}
return nil
}
func hasAnyPlaceholders(u *url.URL) bool {
if strings.Contains(u.Path, placeholderPrefix) {
return true
}
if len(u.Query()) == 0 {
return false
}
for _, values := range u.Query() {
for _, value := range values {
if strings.HasPrefix(value, placeholderPrefix) {
return true
}
}
}
return false
}

View File

@@ -32,14 +32,14 @@ XOtclIk1uhc03oL9nOQ=
ac, err := parseAuthConfig([]byte(s))
if err != nil {
if expErr != err.Error() {
t.Fatalf("unexpected error; got %q; want %q", err.Error(), expErr)
t.Fatalf("unexpected error; got\n%q\nwant\n%q", err.Error(), expErr)
}
return
}
users, err := parseJWTUsers(ac)
if err != nil {
if expErr != err.Error() {
t.Fatalf("unexpected error; got %q; want %q", err.Error(), expErr)
t.Fatalf("unexpected error; got\n%q\nwant \n%q", err.Error(), expErr)
}
return
}
@@ -164,6 +164,38 @@ users:
- `+publicKeyFile+`
url_prefix: http://foo.bar
`, "cannot parse public key from file \""+publicKeyFile+"\": failed to parse key \"invalidPEM\": failed to decode PEM block containing public key")
// unsupported placeholder in a header
f(`
users:
- jwt:
skip_verify: true
url_prefix: http://foo.bar/{{.UnsupportedPlaceholder}}/foo`,
"invalid placeholder found in URL request path: \"/{{.UnsupportedPlaceholder}}/foo\", supported values are: {{.MetricsTenant}}, {{.MetricsExtraLabels}}, {{.MetricsExtraFilters}}, {{.LogsAccountID}}, {{.LogsProjectID}}, {{.LogsExtraFilters}}, {{.LogsExtraStreamFilters}}",
)
// unsupported placeholder in a header
f(`
users:
- jwt:
skip_verify: true
headers:
- "AccountID: {{.UnsupportedPlaceholder}}"
url_prefix: http://foo.bar
`,
"request header: \"AccountID\" has unsupported placeholder: \"{{.UnsupportedPlaceholder}}\", supported values are: {{.MetricsTenant}}, {{.MetricsExtraLabels}}, {{.MetricsExtraFilters}}, {{.LogsAccountID}}, {{.LogsProjectID}}, {{.LogsExtraFilters}}, {{.LogsExtraStreamFilters}}",
)
// spaces in templating not allowed
f(`
users:
- jwt:
skip_verify: true
headers:
- "AccountID: {{ .LogsAccountID }}"
url_prefix: http://foo.bar
`,
"request header: \"AccountID\" has unsupported placeholder: \"{{ .LogsAccountID }}\", supported values are: {{.MetricsTenant}}, {{.MetricsExtraLabels}}, {{.MetricsExtraFilters}}, {{.LogsAccountID}}, {{.LogsProjectID}}, {{.LogsExtraFilters}}, {{.LogsExtraStreamFilters}}",
)
}
func TestJWTParseAuthConfigSuccess(t *testing.T) {

View File

@@ -16,6 +16,7 @@ import (
"sync"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/jwt"
"github.com/VictoriaMetrics/metrics"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/buildinfo"
@@ -173,7 +174,7 @@ func requestHandler(w http.ResponseWriter, r *http.Request) bool {
// Process requests for unauthorized users
ui := authConfig.Load().UnauthorizedUser
if ui != nil {
processUserRequest(w, r, ui)
processUserRequest(w, r, ui, nil)
return true
}
@@ -182,17 +183,21 @@ func requestHandler(w http.ResponseWriter, r *http.Request) bool {
}
if ui := getUserInfoByAuthTokens(ats); ui != nil {
processUserRequest(w, r, ui)
processUserRequest(w, r, ui, nil)
return true
}
if ui := getUserInfoByJWTToken(ats); ui != nil {
processUserRequest(w, r, ui)
if ui, tkn := getUserInfoByJWTToken(ats); ui != nil {
if tkn == nil {
logger.Panicf("BUG: unexpected nil jwt token for user %q", ui.name())
}
processUserRequest(w, r, ui, tkn)
return true
}
uu := authConfig.Load().UnauthorizedUser
if uu != nil {
processUserRequest(w, r, uu)
processUserRequest(w, r, uu, nil)
return true
}
@@ -221,7 +226,7 @@ func getUserInfoByAuthTokens(ats []string) *UserInfo {
return nil
}
func processUserRequest(w http.ResponseWriter, r *http.Request, ui *UserInfo) {
func processUserRequest(w http.ResponseWriter, r *http.Request, ui *UserInfo, tkn *jwt.Token) {
startTime := time.Now()
defer ui.requestsDuration.UpdateDuration(startTime)
@@ -272,7 +277,7 @@ func processUserRequest(w http.ResponseWriter, r *http.Request, ui *UserInfo) {
defer ui.endConcurrencyLimit()
// Process the request.
processRequest(w, r, ui)
processRequest(w, r, ui, tkn)
}
func beginConcurrencyLimit(ctx context.Context) error {
@@ -345,7 +350,7 @@ func bufferRequestBody(ctx context.Context, r io.ReadCloser, userName string) (i
return bb, nil
}
func processRequest(w http.ResponseWriter, r *http.Request, ui *UserInfo) {
func processRequest(w http.ResponseWriter, r *http.Request, ui *UserInfo, tkn *jwt.Token) {
u := normalizeURL(r.URL)
up, hc := ui.getURLPrefixAndHeaders(u, r.Host, r.Header)
isDefault := false
@@ -377,6 +382,10 @@ func processRequest(w http.ResponseWriter, r *http.Request, ui *UserInfo) {
break
}
targetURL := bu.url
if tkn != nil {
// for security reasons allow templating only for configured url values and headers
targetURL, hc = replaceJWTPlaceholders(bu, hc, tkn.VMAccess())
}
if isDefault {
// Don't change path and add request_path query param for default route.
query := targetURL.Query()
@@ -386,7 +395,6 @@ func processRequest(w http.ResponseWriter, r *http.Request, ui *UserInfo) {
// Update path for regular routes.
targetURL = mergeURLs(targetURL, u, up.dropSrcPathPrefixParts, up.mergeQueryArgs)
}
wasLocalRetry := false
again:
ok, needLocalRetry := tryProcessingRequest(w, r, targetURL, hc, up.retryStatusCodes, ui, bu)

View File

@@ -17,6 +17,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"testing"
@@ -571,22 +572,41 @@ func TestJWTRequestHandler(t *testing.T) {
return payload + "." + signatureB64
}
genToken(t, nil, false)
f := func(cfgStr string, r *http.Request, responseExpected string) {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := w.Write([]byte(r.RequestURI + "\n")); err != nil {
if _, err := w.Write([]byte("path: " + r.URL.Path + "\n")); err != nil {
panic(fmt.Errorf("cannot write response: %w", err))
}
if v := r.Header.Get(`extra_label`); v != "" {
if _, err := w.Write([]byte(`extra_label=` + v + "\n")); err != nil {
if _, err := w.Write([]byte("query:\n")); err != nil {
panic(fmt.Errorf("cannot write response: %w", err))
}
names := make([]string, 0, len(r.URL.Query()))
query := r.URL.Query()
for n := range query {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
for _, v := range query[n] {
if _, err := w.Write([]byte(" " + n + "=" + v + "\n")); err != nil {
panic(fmt.Errorf("cannot write response: %w", err))
}
}
}
if _, err := w.Write([]byte("headers:\n")); err != nil {
panic(fmt.Errorf("cannot write response: %w", err))
}
if v := r.Header.Get(`AccountID`); v != "" {
if _, err := w.Write([]byte(` AccountID=` + v + "\n")); err != nil {
panic(fmt.Errorf("cannot write response: %w", err))
}
}
if v := r.Header.Get(`extra_filters`); v != "" {
if _, err := w.Write([]byte(`extra_filters=` + v + "\n")); err != nil {
if v := r.Header.Get(`ProjectID`); v != "" {
if _, err := w.Write([]byte(` ProjectID=` + v + "\n")); err != nil {
panic(fmt.Errorf("cannot write response: %w", err))
}
}
@@ -632,7 +652,7 @@ users:
- %q
url_prefix: {BACKEND}/foo`, string(publicKeyPEM))
noVMAccessClaimToken := genToken(t, nil, true)
defaultVMAccessClaimToken := genToken(t, map[string]any{
minimalToken := genToken(t, map[string]any{
"exp": time.Now().Add(10 * time.Minute).Unix(),
"vm_access": map[string]any{},
}, true)
@@ -645,6 +665,30 @@ users:
"vm_access": map[string]any{},
}, false)
fullToken := genToken(t, map[string]any{
"exp": time.Now().Add(10 * time.Minute).Unix(),
"vm_access": map[string]any{
"metrics_account_id": 123,
"metrics_project_id": 234,
"metrics_extra_labels": []string{
"label1=value1",
"label2=value2",
},
"metrics_extra_filters": []string{
`{label3="value3"}`,
`{label4="value4"}`,
},
"logs_account_id": 345,
"logs_project_id": 456,
"logs_extra_filters": []string{
`{"namespace":"my-app","env":"prod"}`,
},
"logs_extra_stream_filters": []string{
`{"team":"dev"}`,
},
},
}, true)
// missing authorization
request := httptest.NewRequest(`GET`, "http://some-host.com/abc", nil)
responseExpected := `
@@ -682,7 +726,9 @@ Unauthorized`
request.Header.Set(`Authorization`, `Bearer `+invalidSignatureToken)
responseExpected = `
statusCode=200
/foo/abc`
path: /foo/abc
query:
headers:`
f(`
users:
- jwt:
@@ -691,15 +737,17 @@ users:
// token with default valid vm_access claim
request = httptest.NewRequest(`GET`, "http://some-host.com/abc", nil)
request.Header.Set(`Authorization`, `Bearer `+defaultVMAccessClaimToken)
request.Header.Set(`Authorization`, `Bearer `+minimalToken)
responseExpected = `
statusCode=200
/foo/abc`
path: /foo/abc
query:
headers:`
f(simpleCfgStr, request, responseExpected)
// jwt token used but no matching user with JWT token in config
request = httptest.NewRequest(`GET`, "http://some-host.com/abc", nil)
request.Header.Set(`Authorization`, `Bearer `+defaultVMAccessClaimToken)
request.Header.Set(`Authorization`, `Bearer `+minimalToken)
responseExpected = `
statusCode=401
Unauthorized`
@@ -715,16 +763,479 @@ users:
t.Fatalf("failed to write public key file: %s", err)
}
request = httptest.NewRequest(`GET`, "http://some-host.com/abc", nil)
request.Header.Set(`Authorization`, `Bearer `+defaultVMAccessClaimToken)
request.Header.Set(`Authorization`, `Bearer `+minimalToken)
responseExpected = `
statusCode=200
/foo/abc`
path: /foo/abc
query:
headers:`
f(fmt.Sprintf(`
users:
- jwt:
public_key_files:
- %q
url_prefix: {BACKEND}/foo`, string(publicKeyFile)), request, responseExpected)
url_prefix: {BACKEND}/foo`, publicKeyFile), request, responseExpected)
// ---- VictoriaMetrics specific tests ----
// extra_label and extra_filters dropped if empty in vm_access claim
request = httptest.NewRequest(`GET`, "http://some-host.com/api/v1/query", nil)
request.Header.Set(`Authorization`, `Bearer `+minimalToken)
responseExpected = `
statusCode=200
path: /select/0:0/api/v1/query
query:
headers:`
f(fmt.Sprintf(
`
users:
- jwt:
public_keys:
- %q
url_prefix: {BACKEND}/select/{{.MetricsTenant}}/?extra_label={{.MetricsExtraLabels}}&extra_filters={{.MetricsExtraFilters}}`, string(publicKeyPEM)),
request,
responseExpected,
)
// extra_label and extra_filters set if present in vm_access claim
request = httptest.NewRequest(`GET`, "http://some-host.com/api/v1/query", nil)
request.Header.Set(`Authorization`, `Bearer `+fullToken)
responseExpected = `
statusCode=200
path: /select/123:234/api/v1/query
query:
extra_filters={label3="value3"}
extra_filters={label4="value4"}
extra_label=label1=value1
extra_label=label2=value2
headers:`
f(fmt.Sprintf(
`
users:
- jwt:
public_keys:
- %q
url_prefix: {BACKEND}/select/{{.MetricsTenant}}/?extra_label={{.MetricsExtraLabels}}&extra_filters={{.MetricsExtraFilters}}`, string(publicKeyPEM)),
request,
responseExpected,
)
// extra_label and extra_filters from vm_access claim merged with statically defined
request = httptest.NewRequest(`GET`, "http://some-host.com/api/v1/query", nil)
request.Header.Set(`Authorization`, `Bearer `+fullToken)
responseExpected = `
statusCode=200
path: /select/123:234/api/v1/query
query:
extra_filters=aStaticFilter
extra_filters={label3="value3"}
extra_filters={label4="value4"}
extra_label=aStaticLabel
extra_label=label1=value1
extra_label=label2=value2
headers:`
f(fmt.Sprintf(
`
users:
- jwt:
public_keys:
- %q
url_prefix: {BACKEND}/select/{{.MetricsTenant}}/?extra_label=aStaticLabel&extra_filters=aStaticFilter&extra_label={{.MetricsExtraLabels}}&extra_filters={{.MetricsExtraFilters}}`, string(publicKeyPEM)),
request,
responseExpected,
)
// extra_labels and extra_filters set from vm_access claim should override user provided query args
request = httptest.NewRequest(`GET`, "http://some-host.com/api/v1/query?extra_label=userProvidedLabel&extra_filters=userProvidedFilter", nil)
request.Header.Set(`Authorization`, `Bearer `+fullToken)
responseExpected = `
statusCode=200
path: /select/123:234/api/v1/query
query:
extra_filters={label3="value3"}
extra_filters={label4="value4"}
extra_label=label1=value1
extra_label=label2=value2
headers:`
f(
fmt.Sprintf(`
users:
- jwt:
public_keys:
- %q
url_prefix: {BACKEND}/select/{{.MetricsTenant}}/?extra_label={{.MetricsExtraLabels}}&extra_filters={{.MetricsExtraFilters}}`, string(publicKeyPEM)),
request,
responseExpected,
)
// merge user provided query args with extra_labels and extra_filters from vm_access claim
request = httptest.NewRequest(`GET`, "http://some-host.com/api/v1/query?extra_label=userProvidedLabel&extra_filters=userProvidedFilter", nil)
request.Header.Set(`Authorization`, `Bearer `+fullToken)
responseExpected = `
statusCode=200
path: /select/123:234/api/v1/query
query:
extra_filters={label3="value3"}
extra_filters={label4="value4"}
extra_filters=userProvidedFilter
extra_label=label1=value1
extra_label=label2=value2
extra_label=userProvidedLabel
headers:`
f(fmt.Sprintf(`
users:
- jwt:
public_keys:
- %q
merge_query_args: [extra_filters, extra_label]
url_prefix: {BACKEND}/select/{{.MetricsTenant}}/?extra_label={{.MetricsExtraLabels}}&extra_filters={{.MetricsExtraFilters}}`, string(publicKeyPEM)),
request,
responseExpected,
)
// pass user provided query args if vm_access claim has no extra_labels and extra_filters
request = httptest.NewRequest(`GET`, "http://some-host.com/api/v1/query?extra_label=userProvidedLabel&extra_filters=userProvidedFilter", nil)
request.Header.Set(`Authorization`, `Bearer `+fullToken)
responseExpected = `
statusCode=200
path: /select/123:234/api/v1/query
query:
extra_filters=userProvidedFilter
extra_label=userProvidedLabel
headers:`
f(fmt.Sprintf(`
users:
- jwt:
public_keys:
- %q
merge_query_args: [extra_filters, extra_label]
url_prefix: {BACKEND}/select/{{.MetricsTenant}}/`, string(publicKeyPEM)),
request,
responseExpected,
)
// pass user provided query args if vm_access claim has no extra_labels and extra_filters
request = httptest.NewRequest(`GET`, "http://some-host.com/api/v1/query?extra_label=userProvidedLabel&extra_filters=userProvidedFilter", nil)
request.Header.Set(`Authorization`, `Bearer `+fullToken)
responseExpected = `
statusCode=200
path: /select/123:234/api/v1/query
query:
extra_filters=userProvidedFilter
extra_label=userProvidedLabel
headers:`
f(fmt.Sprintf(`
users:
- jwt:
public_keys:
- %q
url_prefix: {BACKEND}/select/{{.MetricsTenant}}/`, string(publicKeyPEM)),
request,
responseExpected,
)
// placeholders in url_map
request = httptest.NewRequest(`GET`, "http://some-host.com/api/v1/query", nil)
request.Header.Set(`Authorization`, `Bearer `+fullToken)
responseExpected = `
statusCode=200
path: /select/123:234/api/v1/query
query:
extra_filters={label3="value3"}
extra_filters={label4="value4"}
extra_label=label1=value1
extra_label=label2=value2
headers:`
f(fmt.Sprintf(
`
users:
- jwt:
public_keys:
- %q
url_map:
- src_paths: ["/api/.*"]
url_prefix: {BACKEND}/select/{{.MetricsTenant}}/?extra_label={{.MetricsExtraLabels}}&extra_filters={{.MetricsExtraFilters}}`, string(publicKeyPEM)),
request,
responseExpected,
)
// ---- VictoriaLogs specific tests ----
// tenant headers not overwritten if set statically
// extra_filters extra_stream_filters dropped if empty in vm_access claim
request = httptest.NewRequest(`GET`, "http://some-host.com/query", nil)
request.Header.Set(`Authorization`, `Bearer `+minimalToken)
responseExpected = `
statusCode=200
path: /select/logsql/query
query:
headers:
AccountID=555
ProjectID=666`
f(
fmt.Sprintf(`
users:
- jwt:
public_keys:
- %q
headers:
- "AccountID: 555"
- "ProjectID: 666"
url_prefix: {BACKEND}/select/logsql/?extra_filters={{.LogsExtraFilters}}&extra_stream_filters={{.LogsExtraStreamFilters}}`, string(publicKeyPEM)),
request,
responseExpected,
)
// tenant headers are overwritten if set as placeholders
request = httptest.NewRequest(`GET`, "http://some-host.com/query", nil)
request.Header.Set(`Authorization`, `Bearer `+minimalToken)
responseExpected = `
statusCode=200
path: /select/logsql/query
query:
headers:
AccountID=0
ProjectID=0`
f(
fmt.Sprintf(`
users:
- jwt:
public_keys:
- %q
headers:
- "AccountID: {{.LogsAccountID}}"
- "ProjectID: {{.LogsProjectID}}"
url_prefix: {BACKEND}/select/logsql/?extra_filters={{.LogsExtraFilters}}&extra_stream_filters={{.LogsExtraStreamFilters}}`, string(publicKeyPEM)),
request,
responseExpected,
)
// tenant headers are overwritten if set as placeholders
// extra_filters extra_stream_filters from vm_access claim merged with statically defined
request = httptest.NewRequest(`GET`, "http://some-host.com/query", nil)
request.Header.Set(`Authorization`, `Bearer `+fullToken)
responseExpected = `
statusCode=200
path: /select/logsql/query
query:
extra_filters=aStaticFilter
extra_filters={"namespace":"my-app","env":"prod"}
extra_stream_filters=aStaticStreamFilter
extra_stream_filters={"team":"dev"}
headers:
AccountID=345
ProjectID=456`
f(
fmt.Sprintf(`
users:
- jwt:
public_keys:
- %q
headers:
- "AccountID: {{.LogsAccountID}}"
- "ProjectID: {{.LogsProjectID}}"
url_prefix: {BACKEND}/select/logsql/?extra_filters=aStaticFilter&extra_stream_filters=aStaticStreamFilter&extra_filters={{.LogsExtraFilters}}&extra_stream_filters={{.LogsExtraStreamFilters}}`, string(publicKeyPEM)),
request,
responseExpected,
)
// tenant headers are overwritten if set as placeholders
// extra_filters extra_stream_filters from vm_access claim merged with statically defined
request = httptest.NewRequest(`GET`, "http://some-host.com/query", nil)
request.Header.Set(`Authorization`, `Bearer `+fullToken)
responseExpected = `
statusCode=200
path: /select/logsql/query
query:
extra_filters=aStaticFilter
extra_filters={"namespace":"my-app","env":"prod"}
extra_stream_filters=aStaticStreamFilter
extra_stream_filters={"team":"dev"}
headers:
AccountID=345
ProjectID=456`
f(
fmt.Sprintf(`
users:
- jwt:
public_keys:
- %q
headers:
- "AccountID: {{.LogsAccountID}}"
- "ProjectID: {{.LogsProjectID}}"
url_prefix: {BACKEND}/select/logsql/?extra_filters=aStaticFilter&extra_stream_filters=aStaticStreamFilter&extra_filters={{.LogsExtraFilters}}&extra_stream_filters={{.LogsExtraStreamFilters}}`, string(publicKeyPEM)),
request,
responseExpected,
)
// claim info should overwrite user provided query args and headers
request = httptest.NewRequest(`GET`, "http://some-host.com/query?extra_filters=aUserFilter&extra_stream_filters=aUserStreamFilter", nil)
request.Header.Set(`Authorization`, `Bearer `+fullToken)
request.Header.Set(`AccountID`, `aUserAccountID`)
request.Header.Set(`ProjectID`, `aUserProjectID`)
responseExpected = `
statusCode=200
path: /select/logsql/query
query:
extra_filters={"namespace":"my-app","env":"prod"}
extra_stream_filters={"team":"dev"}
headers:
AccountID=345
ProjectID=456`
f(
fmt.Sprintf(`
users:
- jwt:
public_keys:
- %q
headers:
- "AccountID: {{.LogsAccountID}}"
- "ProjectID: {{.LogsProjectID}}"
url_prefix: {BACKEND}/select/logsql/?extra_filters={{.LogsExtraFilters}}&extra_stream_filters={{.LogsExtraStreamFilters}}`, string(publicKeyPEM)),
request,
responseExpected,
)
// merge user provided query args with extra_filters and extra_stream_filters from vm_access claim
request = httptest.NewRequest(`GET`, "http://some-host.com/query?extra_filters=aUserFilter&extra_stream_filters=aUserStreamFilter", nil)
request.Header.Set(`Authorization`, `Bearer `+fullToken)
responseExpected = `
statusCode=200
path: /select/logsql/query
query:
extra_filters={"namespace":"my-app","env":"prod"}
extra_filters=aUserFilter
extra_stream_filters={"team":"dev"}
extra_stream_filters=aUserStreamFilter
headers:
AccountID=345
ProjectID=456`
f(
fmt.Sprintf(`
users:
- jwt:
public_keys:
- %q
headers:
- "AccountID: {{.LogsAccountID}}"
- "ProjectID: {{.LogsProjectID}}"
merge_query_args: [extra_filters, extra_stream_filters]
url_prefix: {BACKEND}/select/logsql/?extra_filters={{.LogsExtraFilters}}&extra_stream_filters={{.LogsExtraStreamFilters}}`, string(publicKeyPEM)),
request,
responseExpected,
)
// pass user provided query args if vm_access claim has no extra_labels and extra_filters
request = httptest.NewRequest(`GET`, "http://some-host.com/query?extra_filters=aUserFilter&extra_stream_filters=aUserStreamFilter", nil)
request.Header.Set(`Authorization`, `Bearer `+minimalToken)
responseExpected = `
statusCode=200
path: /select/logsql/query
query:
extra_filters=aUserFilter
extra_stream_filters=aUserStreamFilter
headers:
AccountID=0
ProjectID=0`
f(
fmt.Sprintf(`
users:
- jwt:
public_keys:
- %q
headers:
- "AccountID: {{.LogsAccountID}}"
- "ProjectID: {{.LogsProjectID}}"
merge_query_args: [extra_filters, extra_stream_filters]
url_prefix: {BACKEND}/select/logsql/?extra_filters={{.LogsExtraFilters}}&extra_stream_filters={{.LogsExtraStreamFilters}}`, string(publicKeyPEM)),
request,
responseExpected,
)
// placeholders in url_map
request = httptest.NewRequest(`GET`, "http://some-host.com/query", nil)
request.Header.Set(`Authorization`, `Bearer `+fullToken)
responseExpected = `
statusCode=200
path: /select/logsql/query
query:
extra_filters={"namespace":"my-app","env":"prod"}
extra_stream_filters={"team":"dev"}
headers:
AccountID=345
ProjectID=456`
f(fmt.Sprintf(
`
users:
- jwt:
public_keys:
- %q
url_map:
- src_paths: ["/query"]
headers:
- "AccountID: {{.LogsAccountID}}"
- "ProjectID: {{.LogsProjectID}}"
url_prefix: {BACKEND}/select/logsql/?extra_filters={{.LogsExtraFilters}}&extra_stream_filters={{.LogsExtraStreamFilters}}`, string(publicKeyPEM)),
request,
responseExpected,
)
// multiple placeholders in url_map for the same param
request = httptest.NewRequest(`GET`, "http://some-host.com/query", nil)
request.Header.Set(`Authorization`, `Bearer `+fullToken)
responseExpected = `
statusCode=200
path: /select/logsql/query
query:
extra_filters={"namespace":"my-app","env":"prod"}
extra_stream_filters={"team":"dev"}
tenant_info=static=value
tenant_info=345
tenant_info=456
headers:
AccountID=345
ProjectID=456`
f(fmt.Sprintf(
`
users:
- jwt:
public_keys:
- %q
url_map:
- src_paths: ["/query"]
headers:
- "AccountID: {{.LogsAccountID}}"
- "ProjectID: {{.LogsProjectID}}"
url_prefix: {BACKEND}/select/logsql/?extra_filters={{.LogsExtraFilters}}&extra_stream_filters={{.LogsExtraStreamFilters}}&tenant_info=static=value&tenant_info={{.LogsAccountID}}&tenant_info={{.LogsProjectID}}`, string(publicKeyPEM)),
request,
responseExpected,
)
// client request params must be ignored by placeholders
request = httptest.NewRequest(`GET`, "http://some-host.com/query?template_attack={{.LogsExtraFilters}}", nil)
request.Header.Set(`Authorization`, `Bearer `+fullToken)
request.Header.Set(`AccountID`, `{{.LogsAccountID}}`)
responseExpected = `
statusCode=200
path: /select/logsql/query
query:
extra_filters={"namespace":"my-app","env":"prod"}
extra_stream_filters={"team":"dev"}
template_attack={{.LogsExtraFilters}}
headers:
AccountID={{.LogsAccountID}}`
f(fmt.Sprintf(
`
users:
- jwt:
public_keys:
- %q
url_map:
- src_paths: ["/query"]
url_prefix: {BACKEND}/select/logsql/?extra_filters={{.LogsExtraFilters}}&extra_stream_filters={{.LogsExtraStreamFilters}}`, string(publicKeyPEM)),
request,
responseExpected,
)
}
type fakeResponseWriter struct {

View File

@@ -5,6 +5,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"math"
"net/http"
"slices"
"strings"
@@ -47,10 +48,10 @@ type body struct {
// expired at time unix_ts
Exp int64 `json:"exp"`
// issued at time unix_ts
Iat int64 `json:"iat"`
Jti string `json:"jti,omitempty"`
Scope string `json:"scope,omitempty"`
VMAccess *access `json:"vm_access"`
Iat int64 `json:"iat"`
Jti string `json:"jti,omitempty"`
Scope string `json:"scope,omitempty"`
VMAccess *VMAccessClaim `json:"vm_access"`
}
// Labels defines labels added to filters or incoming time series.
@@ -70,7 +71,7 @@ func (l Labels) AsExtraLabels() []string {
return res
}
type access struct {
type VMAccessClaim struct {
Tenant TenantID `json:"tenant_id"`
Labels Labels `json:"extra_labels,omitempty"`
// promql filters applied to each select query
@@ -78,6 +79,86 @@ type access struct {
// role can be denied as 1 = read, 2 = write, 3 = read and write
// 0 = unconfigured - read and write
Mode int `json:"mode,omitempty"`
// TODO: use different claim struct for vmauth and vmgateway
// parsing must be dynamic based on provided hint
MetricsAccountID uint32 `json:"-"`
MetricsProjectID uint32 `json:"-"`
MetricsExtraFilters []string `json:"metrics_extra_filters,omitempty"`
MetricsExtraLabels []string `json:"metrics_extra_labels,omitempty"`
LogsAccountID uint32 `json:"-"`
LogsProjectID uint32 `json:"-"`
LogsExtraFilters []string `json:"logs_extra_filters,omitempty"`
LogsExtraStreamFilters []string `json:"logs_extra_stream_filters,omitempty"`
}
// UnmarshalJSON implements custom JSON unmarshalling for VMAccessClaim.
// It deserializes MetricsAccountID, MetricsProjectID, LogsAccountID and LogsProjectID
// from JSON numbers as int64 first, then performs range-checked conversion to uint32.
// This maintains backward compatibility with JWT tokens that may carry values
// representable as int64 but not directly as uint32 (e.g., negative values from
// previously signed int fields).
func (vma *VMAccessClaim) UnmarshalJSON(data []byte) error {
type vmAccessClaimJSON struct {
Tenant TenantID `json:"tenant_id"`
Labels Labels `json:"extra_labels,omitempty"`
// promql filters applied to each select query
ExtraFilters []string `json:"extra_filters,omitempty"`
Mode int `json:"mode,omitempty"`
MetricsAccountID int64 `json:"metrics_account_id,omitempty"`
MetricsProjectID int64 `json:"metrics_project_id,omitempty"`
MetricsExtraFilters []string `json:"metrics_extra_filters,omitempty"`
MetricsExtraLabels []string `json:"metrics_extra_labels,omitempty"`
LogsAccountID int64 `json:"logs_account_id,omitempty"`
LogsProjectID int64 `json:"logs_project_id,omitempty"`
LogsExtraFilters []string `json:"logs_extra_filters,omitempty"`
LogsExtraStreamFilters []string `json:"logs_extra_stream_filters,omitempty"`
}
var raw vmAccessClaimJSON
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
vma.Tenant = raw.Tenant
vma.Labels = raw.Labels
vma.ExtraFilters = raw.ExtraFilters
vma.Mode = raw.Mode
vma.MetricsExtraFilters = raw.MetricsExtraFilters
vma.MetricsExtraLabels = raw.MetricsExtraLabels
vma.LogsExtraFilters = raw.LogsExtraFilters
vma.LogsExtraStreamFilters = raw.LogsExtraStreamFilters
var err error
vma.MetricsAccountID, err = safeUint32("metrics_account_id", raw.MetricsAccountID)
if err != nil {
return err
}
vma.MetricsProjectID, err = safeUint32("metrics_project_id", raw.MetricsProjectID)
if err != nil {
return err
}
vma.LogsAccountID, err = safeUint32("logs_account_id", raw.LogsAccountID)
if err != nil {
return err
}
vma.LogsProjectID, err = safeUint32("logs_project_id", raw.LogsProjectID)
if err != nil {
return err
}
return nil
}
// safeUint32 converts an int64 JSON value to uint32 with range checking.
func safeUint32(field string, v int64) (uint32, error) {
if v < 0 || v > math.MaxUint32 {
return 0, fmt.Errorf("field %q value %d is out of uint32 range [0, %d]", field, v, uint32(math.MaxUint32))
}
return uint32(v), nil
}
// TenantID represents tenantID.
@@ -175,7 +256,7 @@ func (t *Token) CanRead() bool {
return false
}
// AccessLabels returns access labels for given JWT token,
// AccessLabels returns vm_access labels for given JWT token,
// in key=value format.
func (t *Token) AccessLabels() []string {
return t.body.VMAccess.Labels.AsExtraLabels()
@@ -191,6 +272,10 @@ func (t *Token) ExtraFilters() []string {
return t.body.VMAccess.ExtraFilters
}
func (t *Token) VMAccess() *VMAccessClaim {
return t.body.VMAccess
}
func parseJWTHeader(data string) (*header, error) {
var jh header
decoded, err := decodeB64([]byte(data))
@@ -230,11 +315,12 @@ func parseJWTBody(data string) (*body, error) {
// some IDPs encode custom claims as a string
// try parsing as an object and fallback to a string
var a access
if err := json.Unmarshal(*tb.VMAccess, &a); err != nil {
var a VMAccessClaim
if unmarshalErr := json.Unmarshal(*tb.VMAccess, &a); unmarshalErr != nil {
var s string
if err := json.Unmarshal(*tb.VMAccess, &s); err != nil {
return nil, fmt.Errorf("cannot parse jwt body vm_access: %w", err)
// raw value is not a string either; return the original object unmarshal error
return nil, fmt.Errorf("cannot parse jwt body vm_access: %w", unmarshalErr)
}
if err := json.Unmarshal([]byte(s), &a); err != nil {

View File

@@ -141,7 +141,7 @@ func TestParseJWTBody_Failure(t *testing.T) {
// vm_access claim invalid type
f(
`{"vm_access": 123}`,
"cannot parse jwt body vm_access: json: cannot unmarshal number into Go value of type string",
"cannot parse jwt body vm_access: json: cannot unmarshal number into Go value of type jwt.vmAccessClaimJSON",
true,
)
@@ -155,14 +155,14 @@ func TestParseJWTBody_Failure(t *testing.T) {
// invalid vm_access: account_id type mismatch
f(
`{"vm_access": {"tenant_id": {"account_id": "1", "project_id": 5}}}`,
`cannot parse jwt body vm_access: json: cannot unmarshal object into Go value of type string`,
`cannot parse jwt body vm_access: json: cannot unmarshal string into Go struct field TenantID.tenant_id.account_id of type int32`,
true,
)
// invalid vm_access: project_id type mismatch
f(
`{"vm_access": {"tenant_id": {"account_id": 1, "project_id": "5"}}}`,
`cannot parse jwt body vm_access: json: cannot unmarshal object into Go value of type string`,
`cannot parse jwt body vm_access: json: cannot unmarshal string into Go struct field TenantID.tenant_id.project_id of type int32`,
true,
)
@@ -180,7 +180,7 @@ func TestParseJWTBody_Failure(t *testing.T) {
}
}
}`,
`cannot parse jwt body vm_access: json: cannot unmarshal object into Go value of type string`,
`cannot parse jwt body vm_access: json: cannot unmarshal array into Go struct field vmAccessClaimJSON.extra_labels of type jwt.Labels`,
true,
)
@@ -195,7 +195,7 @@ func TestParseJWTBody_Failure(t *testing.T) {
}
}
}`,
`cannot parse jwt body vm_access: json: cannot unmarshal object into Go value of type string`,
`cannot parse jwt body vm_access: json: cannot unmarshal object into Go struct field vmAccessClaimJSON.extra_filters of type string`,
true,
)
@@ -205,6 +205,90 @@ func TestParseJWTBody_Failure(t *testing.T) {
`cannot parse jwt body: json: cannot unmarshal string into Go struct field tbody.exp of type int64`,
true,
)
// invalid metrics metrics_account_id claim value type
f(
`{"vm_access": {"metrics_account_id": "1"}}`,
`cannot parse jwt body vm_access: json: cannot unmarshal string into Go struct field vmAccessClaimJSON.metrics_account_id of type int64`,
true,
)
// invalid metrics metrics_project_id claim value type
f(
`{"vm_access": {"metrics_project_id": "1"}}`,
`cannot parse jwt body vm_access: json: cannot unmarshal string into Go struct field vmAccessClaimJSON.metrics_project_id of type int64`,
true,
)
// invalid metrics metrics_extra_labels claim value type
f(
`{"vm_access": {"metrics_extra_labels": "aString"}}`,
`cannot parse jwt body vm_access: json: cannot unmarshal string into Go struct field vmAccessClaimJSON.metrics_extra_labels of type []string`,
true,
)
// invalid metrics metrics_extra_filters claim value type
f(
`{"vm_access": {"metrics_extra_filters": "aString"}}`,
`cannot parse jwt body vm_access: json: cannot unmarshal string into Go struct field vmAccessClaimJSON.metrics_extra_filters of type []string`,
true,
)
// invalid metrics logs_account_id claim value type
f(
`{"vm_access": {"logs_account_id": "1"}}`,
`cannot parse jwt body vm_access: json: cannot unmarshal string into Go struct field vmAccessClaimJSON.logs_account_id of type int64`,
true,
)
// invalid metrics logs_project_id claim value type
f(
`{"vm_access": {"logs_project_id": "1"}}`,
`cannot parse jwt body vm_access: json: cannot unmarshal string into Go struct field vmAccessClaimJSON.logs_project_id of type int64`,
true,
)
// invalid metrics logs_extra_filters claim value type
f(
`{"vm_access": {"logs_extra_filters": "aString"}}`,
`cannot parse jwt body vm_access: json: cannot unmarshal string into Go struct field vmAccessClaimJSON.logs_extra_filters of type []string`,
true,
)
// invalid metrics logs_extra_stream_filters claim value type
f(
`{"vm_access": {"logs_extra_stream_filters": "aString"}}`,
`cannot parse jwt body vm_access: json: cannot unmarshal string into Go struct field vmAccessClaimJSON.logs_extra_stream_filters of type []string`,
true,
)
// negative metrics_account_id value
f(
`{"vm_access": {"metrics_account_id": -1}}`,
`cannot parse jwt body vm_access: field "metrics_account_id" value -1 is out of uint32 range [0, 4294967295]`,
true,
)
// metrics_project_id exceeding uint32 max
f(
`{"vm_access": {"metrics_project_id": 4294967296}}`,
`cannot parse jwt body vm_access: field "metrics_project_id" value 4294967296 is out of uint32 range [0, 4294967295]`,
true,
)
// negative logs_account_id value
f(
`{"vm_access": {"logs_account_id": -100}}`,
`cannot parse jwt body vm_access: field "logs_account_id" value -100 is out of uint32 range [0, 4294967295]`,
true,
)
// logs_project_id exceeding uint32 max
f(
`{"vm_access": {"logs_project_id": 5000000000}}`,
`cannot parse jwt body vm_access: field "logs_project_id" value 5000000000 is out of uint32 range [0, 4294967295]`,
true,
)
}
func TestParseJWTBody_Success(t *testing.T) {
@@ -240,13 +324,16 @@ func TestParseJWTBody_Success(t *testing.T) {
if !reflect.DeepEqual(result.VMAccess.ExtraFilters, resultExpected.VMAccess.ExtraFilters) {
t.Fatalf("unexpected extra_filters; got %v; want %v", result.VMAccess.ExtraFilters, resultExpected.VMAccess.ExtraFilters)
}
if !reflect.DeepEqual(result.VMAccess, resultExpected.VMAccess) {
t.Fatalf("unexpected VMAccess;\ngot\n%+v\nwant\n%+v", result.VMAccess, resultExpected.VMAccess)
}
}
f(`{"vm_access": {}}`, &body{
VMAccess: &access{},
VMAccess: &VMAccessClaim{},
})
f(`{"vm_access": {"tenant_id": {}}}`, &body{
VMAccess: &access{},
VMAccess: &VMAccessClaim{},
})
f(
@@ -260,7 +347,7 @@ func TestParseJWTBody_Success(t *testing.T) {
}
}`,
&body{
VMAccess: &access{
VMAccess: &VMAccessClaim{
Tenant: TenantID{
ProjectID: 5,
AccountID: 1,
@@ -280,7 +367,7 @@ func TestParseJWTBody_Success(t *testing.T) {
}
}`,
&body{
VMAccess: &access{
VMAccess: &VMAccessClaim{
Labels: Labels{
"project": "dev",
"team": "mobile",
@@ -300,7 +387,7 @@ func TestParseJWTBody_Success(t *testing.T) {
}
}`,
&body{
VMAccess: &access{
VMAccess: &VMAccessClaim{
ExtraFilters: []string{
`{project="dev"}`,
`{team=~"mobile"}`,
@@ -328,7 +415,7 @@ func TestParseJWTBody_Success(t *testing.T) {
}
}`,
&body{
VMAccess: &access{
VMAccess: &VMAccessClaim{
Tenant: TenantID{
ProjectID: 5,
AccountID: 1,
@@ -359,7 +446,88 @@ func TestParseJWTBody_Success(t *testing.T) {
Iat: 1610975889,
Jti: "9b194187-6bb7-4244-9d1b-559eab2ef7f3",
Scope: "openid email profile",
VMAccess: &access{},
VMAccess: &VMAccessClaim{},
},
)
// metrics vm_access claim
f(
`
{
"vm_access": {
"metrics_account_id": 1,
"metrics_project_id": 5,
"metrics_extra_labels": [
"project=dev",
"team=mobile"
],
"metrics_extra_filters": [
"{project=\"dev\"}"
]
}
}`,
&body{
VMAccess: &VMAccessClaim{
MetricsAccountID: 1,
MetricsProjectID: 5,
MetricsExtraLabels: []string{
"project=dev",
"team=mobile",
},
MetricsExtraFilters: []string{
`{project="dev"}`,
},
},
},
)
// logs vm_access claim
f(
`
{
"vm_access": {
"logs_account_id": 1,
"logs_project_id": 5,
"logs_extra_filters": [
"{\"namespace\":\"my-app\",\"env\":\"prod\"}"
],
"logs_extra_stream_filters": [
"{project=\"dev\"}"
]
}
}`,
&body{
VMAccess: &VMAccessClaim{
LogsAccountID: 1,
LogsProjectID: 5,
LogsExtraFilters: []string{
`{"namespace":"my-app","env":"prod"}`,
},
LogsExtraStreamFilters: []string{
`{project="dev"}`,
},
},
},
)
// uint32 max value for metrics_account_id
f(
`
{
"vm_access": {
"metrics_account_id": 4294967295,
"metrics_project_id": 0,
"logs_account_id": 4294967295,
"logs_project_id": 4294967295
}
}`,
&body{
VMAccess: &VMAccessClaim{
MetricsAccountID: 4294967295,
MetricsProjectID: 0,
LogsAccountID: 4294967295,
LogsProjectID: 4294967295,
},
},
)
}
@@ -427,7 +595,7 @@ func TestNewTokenFromRequest_Success(t *testing.T) {
Iat: 1610888966,
Jti: "09a058a2-0752-4ecd-a4e9-b65e85af423f",
Scope: "openid email profile",
VMAccess: &access{
VMAccess: &VMAccessClaim{
Tenant: TenantID{
ProjectID: 5,
AccountID: 1,
@@ -460,7 +628,7 @@ func TestNewTokenFromRequest_Success(t *testing.T) {
Iat: 1610888966,
Jti: "09a058a2-0752-4ecd-a4e9-b65e85af423f",
Scope: "openid email profile",
VMAccess: &access{
VMAccess: &VMAccessClaim{
Tenant: TenantID{
ProjectID: 5,
AccountID: 1,
@@ -489,7 +657,7 @@ func TestNewTokenFromRequest_Success(t *testing.T) {
}
resultExpected = &Token{
body: &body{
VMAccess: &access{
VMAccess: &VMAccessClaim{
Tenant: TenantID{
ProjectID: 0,
AccountID: 1,
@@ -517,7 +685,7 @@ func TestNewTokenFromRequest_Success(t *testing.T) {
}
resultExpected = &Token{
body: &body{
VMAccess: &access{
VMAccess: &VMAccessClaim{
Tenant: TenantID{
ProjectID: 0,
AccountID: 1,
@@ -549,7 +717,7 @@ func TestNewTokenFromRequest_Success(t *testing.T) {
Iat: 1610888966,
Jti: "09a058a2-0752-4ecd-a4e9-b65e85af423f",
Scope: "openid email profile",
VMAccess: &access{
VMAccess: &VMAccessClaim{
Tenant: TenantID{
ProjectID: 5,
AccountID: 1,
@@ -583,7 +751,7 @@ func TestNewTokenFromRequest_Success(t *testing.T) {
Iat: 1610888966,
Jti: "09a058a2-0752-4ecd-a4e9-b65e85af423f",
Scope: "openid email profile",
VMAccess: &access{
VMAccess: &VMAccessClaim{
Tenant: TenantID{
ProjectID: 5,
AccountID: 1,
@@ -616,7 +784,7 @@ func TestNewTokenFromRequest_Success(t *testing.T) {
body: &body{
Exp: 1725629232,
Iat: 1725625332,
VMAccess: &access{
VMAccess: &VMAccessClaim{
Tenant: TenantID{
ProjectID: 5,
AccountID: 1,
@@ -644,7 +812,7 @@ func TestNewTokenFromRequest_Success(t *testing.T) {
body: &body{
Exp: 1725629232,
Iat: 1725625332,
VMAccess: &access{
VMAccess: &VMAccessClaim{
Tenant: TenantID{
ProjectID: 5,
AccountID: 1,