From 3384e639955033050e2a7dc515656bf2aac68b70 Mon Sep 17 00:00:00 2001 From: Andrii Chubatiuk Date: Sat, 12 Jul 2025 15:31:48 +0300 Subject: [PATCH] vmauth: JWT support --- .golangci.yml | 4 +- api/operator/v1beta1/vmauth_types.go | 36 ++++++++-- api/operator/v1beta1/vmuser_types.go | 3 + api/operator/v1beta1/zz_generated.deepcopy.go | 47 +++++++++++++ config/crd/overlay/crd.yaml | 66 +++++++++++++++++++ docs/api.md | 20 ++++++ .../operator/factory/reconcile/hpa.go | 6 +- .../operator/factory/vmauth/vmusers_config.go | 50 +++++++++++++- 8 files changed, 223 insertions(+), 9 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 3141302de..1c0e40d80 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -44,10 +44,12 @@ linters: alias: metav1 - pkg: k8s.io/api/apps/v1 alias: appsv1 - - pkg: k8s.io/api/autoscaling/v2" + - pkg: k8s.io/api/autoscaling/v2 alias: autoscalingv2 - pkg: k8s.io/apimachinery/pkg/api/errors alias: k8serrors + - pkg: k8s.io/api/networking/v1 + alias: networkingv1 formatters: enable: - gofmt diff --git a/api/operator/v1beta1/vmauth_types.go b/api/operator/v1beta1/vmauth_types.go index f2239f8bc..27002d907 100644 --- a/api/operator/v1beta1/vmauth_types.go +++ b/api/operator/v1beta1/vmauth_types.go @@ -7,7 +7,8 @@ import ( "regexp" "strings" - v12 "k8s.io/api/networking/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/utils/ptr" @@ -84,6 +85,9 @@ type VMAuthSpec struct { // will be added after removal of VMUserConfigOptions // currently it has collision with inlined fields // IPFilters VMUserIPFilters `json:"ip_filters,omitempty"` + // OODC represents configuration section for OIDC authorization + // +optional + OIDC []*VMAuthOIDCRealm `json:"jwt,omitempty"` // will be removed at v1.0 release // +deprecated // +kubebuilder:validation:Schemaless @@ -125,6 +129,28 @@ type VMAuthSpec struct { UseProxyProtocol bool `json:"useProxyProtocol,omitempty"` } +// VMAuthOIDCRealm defines OIDC realm parameters +type VMAuthOIDCRealm struct { + // EnforcePrefix requires JWT token to start with "Bearer: " + // +optional + EnforcePrefix bool `json:"enforce_prefix,omitempty"` + // IssuerURL is OpenID Connect issuer URL + // +optional + IssuerURL string `json:"issuer_url,omitempty"` + // JWKsURL is the OpenID Connect JWKS URL + // +optional + JWKsURL string `json:"jwks_url,omitempty"` + // SkipDiscovery allows to skip OIDC discovery and use manually supplied Endpoints + // +optional + SkipDiscovery bool `json:"skip_discovery,omitempty"` + // PublicKeyFiles is a list of paths pointing to public key files in PEM format to use + // for verifying JWT tokens + PublicKeyFiles []string `json:"public_key_files,omitempty"` + // PublicKeySecrets is a list of k8s Secret selectors pointing to public key files in PEM format to use + // for verifying JWT tokens + PublicKeySecrets []*corev1.SecretKeySelector `json:"public_key_secrets,omitempty"` +} + // VMAuthUnauthorizedUserAccessSpec defines unauthorized_user section configuration for vmauth type VMAuthUnauthorizedUserAccessSpec struct { // URLPrefix defines prefix prefix for destination @@ -425,7 +451,9 @@ func (cr *VMAuth) Validate() error { return fmt.Errorf("incorrect cr.spec UnauthorizedAccessConfig options: %w", err) } } - + if len(cr.Spec.OIDC) > 0 && !cr.Spec.License.IsProvided() { + return fmt.Errorf("spec.jwt is only allowed in enterprise mode, but no license provided") + } if cr.Spec.UnauthorizedUserAccessSpec != nil { if err := cr.Spec.UnauthorizedUserAccessSpec.Validate(); err != nil { return fmt.Errorf("incorrect cr.spec.UnauthorizedUserAccess syntax: %w", err) @@ -461,11 +489,11 @@ type EmbeddedIngress struct { // ExtraRules - additional rules for ingress, // must be checked for correctness by user. // +optional - ExtraRules []v12.IngressRule `json:"extraRules,omitempty" yaml:"extraRules,omitempty"` + ExtraRules []networkingv1.IngressRule `json:"extraRules,omitempty" yaml:"extraRules,omitempty"` // ExtraTLS - additional TLS configuration for ingress // must be checked for correctness by user. // +optional - ExtraTLS []v12.IngressTLS `json:"extraTls,omitempty" yaml:"extraTls,omitempty"` + ExtraTLS []networkingv1.IngressTLS `json:"extraTls,omitempty" yaml:"extraTls,omitempty"` // Host defines ingress host parameter for default rule // It will be used, only if TlsHosts is empty // +optional diff --git a/api/operator/v1beta1/vmuser_types.go b/api/operator/v1beta1/vmuser_types.go index ecc1de214..56fb0c1e7 100644 --- a/api/operator/v1beta1/vmuser_types.go +++ b/api/operator/v1beta1/vmuser_types.go @@ -15,6 +15,9 @@ type VMUserSpec struct { // Name of the VMUser object. // +optional Name *string `json:"name,omitempty"` + // ClientID extracted from JWT token + // +optional + ClientID *string `json:"client_id,omitempty"` // UserName basic auth user name for accessing protected endpoint, // will be replaced with metadata.name of VMUser if omitted. // +optional diff --git a/api/operator/v1beta1/zz_generated.deepcopy.go b/api/operator/v1beta1/zz_generated.deepcopy.go index a31a3206a..ba568015b 100644 --- a/api/operator/v1beta1/zz_generated.deepcopy.go +++ b/api/operator/v1beta1/zz_generated.deepcopy.go @@ -5007,6 +5007,37 @@ func (in *VMAuthLoadBalancerSpec) DeepCopy() *VMAuthLoadBalancerSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VMAuthOIDCRealm) DeepCopyInto(out *VMAuthOIDCRealm) { + *out = *in + if in.PublicKeyFiles != nil { + in, out := &in.PublicKeyFiles, &out.PublicKeyFiles + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.PublicKeySecrets != nil { + in, out := &in.PublicKeySecrets, &out.PublicKeySecrets + *out = make([]*v1.SecretKeySelector, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(v1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VMAuthOIDCRealm. +func (in *VMAuthOIDCRealm) DeepCopy() *VMAuthOIDCRealm { + if in == nil { + return nil + } + out := new(VMAuthOIDCRealm) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VMAuthSpec) DeepCopyInto(out *VMAuthSpec) { *out = *in @@ -5067,6 +5098,17 @@ func (in *VMAuthSpec) DeepCopyInto(out *VMAuthSpec) { *out = new(VMAuthUnauthorizedUserAccessSpec) (*in).DeepCopyInto(*out) } + if in.OIDC != nil { + in, out := &in.OIDC, &out.OIDC + *out = make([]*VMAuthOIDCRealm, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(VMAuthOIDCRealm) + (*in).DeepCopyInto(*out) + } + } + } in.VMUserConfigOptions.DeepCopyInto(&out.VMUserConfigOptions) if in.License != nil { in, out := &in.License, &out.License @@ -6751,6 +6793,11 @@ func (in *VMUserSpec) DeepCopyInto(out *VMUserSpec) { *out = new(string) **out = **in } + if in.ClientID != nil { + in, out := &in.ClientID, &out.ClientID + *out = new(string) + **out = **in + } if in.UserName != nil { in, out := &in.UserName, &out.UserName *out = new(string) diff --git a/config/crd/overlay/crd.yaml b/config/crd/overlay/crd.yaml index d8ba7e876..d51198267 100644 --- a/config/crd/overlay/crd.yaml +++ b/config/crd/overlay/crd.yaml @@ -23651,6 +23651,69 @@ spec: and v1.111.0 vmauth version related doc https://docs.victoriametrics.com/victoriametrics/vmauth/#security type: string + jwt: + description: |- + IPFilters global access ip filters + supported only with enterprise version of [vmauth](https://docs.victoriametrics.com/victoriametrics/vmauth/#ip-filters) + will be added after removal of VMUserConfigOptions + currently it has collision with inlined fields + IPFilters VMUserIPFilters `json:"ip_filters,omitempty"` + OODC represents configuration section for OIDC authorization + items: + description: VMAuthOIDCRealm defines OIDC realm parameters + properties: + enforce_prefix: + description: 'EnforcePrefix requires JWT token to start with + "Bearer: "' + type: boolean + issuer_url: + description: IssuerURL is OpenID Connect issuer URL + type: string + jwks_url: + description: JWKsURL is the OpenID Connect JWKS URL + type: string + public_key_files: + description: |- + PublicKeyFiles is a list of paths pointing to public key files in PEM format to use + for verifying JWT tokens + items: + type: string + type: array + public_key_secrets: + description: |- + PublicKeySecrets is a list of k8s Secret selectors pointing to public key files in PEM format to use + for verifying JWT tokens + items: + description: SecretKeySelector selects a key of a Secret. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: array + skip_discovery: + description: SkipDiscovery allows to skip OIDC discovery and + use manually supplied Endpoints + type: boolean + type: object + type: array license: description: |- License allows to configure license key to be used for enterprise features. @@ -38811,6 +38874,9 @@ spec: description: BearerToken Authorization header value for accessing protected endpoint. type: string + client_id: + description: ClientID extracted from JWT token + type: string default_url: description: |- DefaultURLs backend url for non-matching paths filter diff --git a/docs/api.md b/docs/api.md index 84421bb12..8713bcdab 100644 --- a/docs/api.md +++ b/docs/api.md @@ -3547,6 +3547,24 @@ Appears in: [VMAuthLoadBalancer](#vmauthloadbalancer) | volumes#
_[Volume](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#volume-v1-core) array_ | _(Required)_
Volumes allows configuration of additional volumes on the output Deployment/StatefulSet definition.
Volumes specified will be appended to other volumes that are generated.
/ +optional | +#### VMAuthOIDCRealm + + + +VMAuthOIDCRealm defines OIDC realm parameters + +Appears in: [VMAuthSpec](#vmauthspec) + +| Field | Description | +| --- | --- | +| enforce_prefix#
_boolean_ | _(Optional)_
EnforcePrefix requires JWT token to start with "Bearer: " | +| issuer_url#
_string_ | _(Optional)_
IssuerURL is OpenID Connect issuer URL | +| jwks_url#
_string_ | _(Optional)_
JWKsURL is the OpenID Connect JWKS URL | +| public_key_files#
_string array_ | _(Required)_
PublicKeyFiles is a list of paths pointing to public key files in PEM format to use
for verifying JWT tokens | +| public_key_secrets#
_[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#secretkeyselector-v1-core) array_ | _(Required)_
PublicKeySecrets is a list of k8s Secret selectors pointing to public key files in PEM format to use
for verifying JWT tokens | +| skip_discovery#
_boolean_ | _(Optional)_
SkipDiscovery allows to skip OIDC discovery and use manually supplied Endpoints | + + #### VMAuthSpec @@ -3587,6 +3605,7 @@ Appears in: [VMAuth](#vmauth) | initContainers#
_[Container](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#container-v1-core) array_ | _(Optional)_
InitContainers allows adding initContainers to the pod definition.
Any errors during the execution of an initContainer will lead to a restart of the Pod.
More info: https://kubernetes.io/docs/concepts/workloads/pods/init-containers/ | | internalListenPort#
_string_ | _(Optional)_
InternalListenPort instructs vmauth to serve internal routes at given port
available from v0.56.0 operator
and v1.111.0 vmauth version
related doc https://docs.victoriametrics.com/victoriametrics/vmauth/#security | | ip_filters#
_[VMUserIPFilters](#vmuseripfilters)_ | _(Optional)_
IPFilters defines per target src ip filters
supported only with enterprise version of [vmauth](https://docs.victoriametrics.com/victoriametrics/vmauth/#ip-filters) | +| jwt#
_[VMAuthOIDCRealm](#vmauthoidcrealm) array_ | _(Optional)_
IPFilters global access ip filters
supported only with enterprise version of [vmauth](https://docs.victoriametrics.com/victoriametrics/vmauth/#ip-filters)
will be added after removal of VMUserConfigOptions
currently it has collision with inlined fields
IPFilters VMUserIPFilters `json:"ip_filters,omitempty"`
OODC represents configuration section for OIDC authorization | | license#
_[License](#license)_ | _(Optional)_
License allows to configure license key to be used for enterprise features.
Using license key is supported starting from VictoriaMetrics v1.94.0.
See [here](https://docs.victoriametrics.com/victoriametrics/enterprise/) | | load_balancing_policy#
_string_ | _(Optional)_
LoadBalancingPolicy defines load balancing policy to use for backend urls.
Supported policies: least_loaded, first_available.
See [here](https://docs.victoriametrics.com/victoriametrics/vmauth#load-balancing) for more details (default "least_loaded") | | logFormat#
_string_ | _(Optional)_
LogFormat for VMAuth to be configured with. | @@ -4476,6 +4495,7 @@ Appears in: [VMUser](#vmuser) | Field | Description | | --- | --- | | bearerToken#
_string_ | _(Optional)_
BearerToken Authorization header value for accessing protected endpoint. | +| client_id#
_string_ | _(Optional)_
ClientID extracted from JWT token | | default_url#
_string array_ | _(Required)_
DefaultURLs backend url for non-matching paths filter
usually used for default backend with error message | | disable_secret_creation#
_boolean_ | _(Required)_
DisableSecretCreation skips related secret creation for vmuser | | discover_backend_ips#
_boolean_ | _(Required)_
DiscoverBackendIPs instructs discovering URLPrefix backend IPs via DNS. | diff --git a/internal/controller/operator/factory/reconcile/hpa.go b/internal/controller/operator/factory/reconcile/hpa.go index 69b64e3e4..f4a5e56b0 100644 --- a/internal/controller/operator/factory/reconcile/hpa.go +++ b/internal/controller/operator/factory/reconcile/hpa.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - v2 "k8s.io/api/autoscaling/v2" + autoscalingv2 "k8s.io/api/autoscaling/v2" "k8s.io/apimachinery/pkg/api/equality" k8serrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" @@ -16,9 +16,9 @@ import ( ) // HPA creates or update horizontalPodAutoscaler object -func HPA(ctx context.Context, rclient client.Client, newHPA, prevHPA *v2.HorizontalPodAutoscaler) error { +func HPA(ctx context.Context, rclient client.Client, newHPA, prevHPA *autoscalingv2.HorizontalPodAutoscaler) error { return retry.RetryOnConflict(retry.DefaultRetry, func() error { - var currentHPA v2.HorizontalPodAutoscaler + var currentHPA autoscalingv2.HorizontalPodAutoscaler if err := rclient.Get(ctx, types.NamespacedName{Name: newHPA.GetName(), Namespace: newHPA.GetNamespace()}, ¤tHPA); err != nil { if k8serrors.IsNotFound(err) { logger.WithContext(ctx).Info(fmt.Sprintf("creating HPA %s configuration", newHPA.Name)) diff --git a/internal/controller/operator/factory/vmauth/vmusers_config.go b/internal/controller/operator/factory/vmauth/vmusers_config.go index 48f602dc4..354e4dd4c 100644 --- a/internal/controller/operator/factory/vmauth/vmusers_config.go +++ b/internal/controller/operator/factory/vmauth/vmusers_config.go @@ -424,6 +424,42 @@ func generateVMAuthConfig(cr *vmv1beta1.VMAuth, sus *skipableVMUsers, crdCache m return nil, fmt.Errorf("cannot build unauthorized_user config section: %w", err) } + var oidcCfg []yaml.MapSlice + for _, realm := range cr.Spec.OIDC { + if realm == nil { + continue + } + var oidcItem yaml.MapSlice + if realm.SkipDiscovery { + if len(realm.JWKsURL) > 0 { + oidcItem = append(oidcItem, yaml.MapItem{Key: "jwks_url", Value: realm.JWKsURL}) + } + var publicKeyFiles []string + if len(realm.PublicKeyFiles) > 0 { + publicKeyFiles = append(publicKeyFiles, realm.PublicKeyFiles...) + } + for _, ref := range realm.PublicKeySecrets { + file, err := ac.LoadPathFromSecret(build.TLSAssetsResourceKind, cr.Namespace, ref) + if err != nil { + return nil, fmt.Errorf("cannot build jwt config section: %w", err) + } + publicKeyFiles = append(publicKeyFiles, file) + } + if len(publicKeyFiles) > 0 { + oidcItem = append(oidcItem, yaml.MapItem{Key: "public_key_files", Value: publicKeyFiles}) + } + } else { + oidcItem = append(oidcItem, yaml.MapItem{Key: "issuer_url", Value: realm.IssuerURL}) + } + if realm.EnforcePrefix { + oidcItem = append(oidcItem, yaml.MapItem{Key: "enforce_prefix", Value: realm.EnforcePrefix}) + } + oidcCfg = append(oidcCfg, oidcItem) + } + if len(oidcCfg) > 0 { + cfg = append(cfg, yaml.MapItem{Key: "oidc", Value: oidcCfg}) + } + if len(unAuthorizedAccessValue) > 0 { cfg = append(cfg, yaml.MapItem{Key: "unauthorized_user", Value: unAuthorizedAccessValue}) } @@ -789,7 +825,7 @@ func genUserCfg(user *vmv1beta1.VMUser, crdURLCache map[string]string, cr *vmv1b } // generate user access config. - var name, username, password, token string + var name, username, password, token, clientID string if user.Spec.Name != nil { name = *user.Spec.Name } @@ -807,6 +843,9 @@ func genUserCfg(user *vmv1beta1.VMUser, crdURLCache map[string]string, cr *vmv1b password = *user.Spec.Password } + if cr.Spec.License.IsProvided() && user.Spec.ClientID != nil { + clientID = *user.Spec.ClientID + } if user.Spec.BearerToken != nil { token = *user.Spec.BearerToken } @@ -829,6 +868,15 @@ func genUserCfg(user *vmv1beta1.VMUser, crdURLCache map[string]string, cr *vmv1b }) return r, nil } + + if clientID != "" { + r = append(r, yaml.MapItem{ + Key: "client_id", + Value: clientID, + }) + return r, nil + } + // mutate vmuser if username == "" { username = user.Name