diff --git a/api/v2/apisixconsumer_types.go b/api/v2/apisixconsumer_types.go index 7a05f9ca..c430a0de 100644 --- a/api/v2/apisixconsumer_types.go +++ b/api/v2/apisixconsumer_types.go @@ -29,7 +29,13 @@ type ApisixConsumerSpec struct { IngressClassName string `json:"ingressClassName,omitempty" yaml:"ingressClassName,omitempty"` // AuthParameter defines the authentication credentials and configuration for this consumer. - AuthParameter ApisixConsumerAuthParameter `json:"authParameter" yaml:"authParameter"` + // +kubebuilder:validation:Optional + AuthParameter *ApisixConsumerAuthParameter `json:"authParameter,omitempty" yaml:"authParameter,omitempty"` + + // Plugins lists additional consumer-scoped plugins to attach to this consumer. + // These plugins are applied alongside any authentication plugin derived from AuthParameter. + // An enabled plugin with the same name as the auth plugin derived from AuthParameter takes precedence. + Plugins []ApisixRoutePlugin `json:"plugins,omitempty" yaml:"plugins,omitempty"` } // ApisixConsumerStatus defines the observed state of ApisixConsumer. @@ -130,6 +136,7 @@ type ApisixConsumerJwtAuth struct { } // ApisixConsumerJwtAuthValue defines configuration for JWT authentication. +// // For asymmetric algorithms (RS*, ES*, PS*, EdDSA), at least one of public_key // or private_key must be provided. Symmetric algorithms (HS256, HS384, HS512) // and unset algorithm do not require any key field. diff --git a/api/v2/apisixconsumer_validation_test.go b/api/v2/apisixconsumer_validation_test.go index 88fdd1d6..5c421315 100644 --- a/api/v2/apisixconsumer_validation_test.go +++ b/api/v2/apisixconsumer_validation_test.go @@ -109,7 +109,7 @@ func TestApisixConsumer_JwtAuth_SymmetricHS256(t *testing.T) { v := loadApisixConsumerSchema(t) ac := &apisixv2.ApisixConsumer{ Spec: apisixv2.ApisixConsumerSpec{ - AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + AuthParameter: &apisixv2.ApisixConsumerAuthParameter{ JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ Value: &apisixv2.ApisixConsumerJwtAuthValue{ Key: "my-key", @@ -130,7 +130,7 @@ func TestApisixConsumer_JwtAuth_AsymmetricWithWhitespaceOnlyPublicKey(t *testing v := loadApisixConsumerSchema(t) ac := &apisixv2.ApisixConsumer{ Spec: apisixv2.ApisixConsumerSpec{ - AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + AuthParameter: &apisixv2.ApisixConsumerAuthParameter{ JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ Value: &apisixv2.ApisixConsumerJwtAuthValue{ Key: "my-key", @@ -150,7 +150,7 @@ func TestApisixConsumer_JwtAuth_SymmetricHS512(t *testing.T) { v := loadApisixConsumerSchema(t) ac := &apisixv2.ApisixConsumer{ Spec: apisixv2.ApisixConsumerSpec{ - AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + AuthParameter: &apisixv2.ApisixConsumerAuthParameter{ JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ Value: &apisixv2.ApisixConsumerJwtAuthValue{ Key: "my-key", @@ -168,7 +168,7 @@ func TestApisixConsumer_JwtAuth_NoAlgorithmDefaultsToSymmetric(t *testing.T) { v := loadApisixConsumerSchema(t) ac := &apisixv2.ApisixConsumer{ Spec: apisixv2.ApisixConsumerSpec{ - AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + AuthParameter: &apisixv2.ApisixConsumerAuthParameter{ JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ Value: &apisixv2.ApisixConsumerJwtAuthValue{ Key: "my-key", @@ -185,7 +185,7 @@ func TestApisixConsumer_JwtAuth_AsymmetricRS256WithPublicKey(t *testing.T) { v := loadApisixConsumerSchema(t) ac := &apisixv2.ApisixConsumer{ Spec: apisixv2.ApisixConsumerSpec{ - AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + AuthParameter: &apisixv2.ApisixConsumerAuthParameter{ JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ Value: &apisixv2.ApisixConsumerJwtAuthValue{ Key: "my-key", @@ -203,7 +203,7 @@ func TestApisixConsumer_JwtAuth_AsymmetricRS256WithPrivateKey(t *testing.T) { v := loadApisixConsumerSchema(t) ac := &apisixv2.ApisixConsumer{ Spec: apisixv2.ApisixConsumerSpec{ - AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + AuthParameter: &apisixv2.ApisixConsumerAuthParameter{ JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ Value: &apisixv2.ApisixConsumerJwtAuthValue{ Key: "my-key", @@ -221,7 +221,7 @@ func TestApisixConsumer_JwtAuth_AsymmetricRS256WithBothKeys(t *testing.T) { v := loadApisixConsumerSchema(t) ac := &apisixv2.ApisixConsumer{ Spec: apisixv2.ApisixConsumerSpec{ - AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + AuthParameter: &apisixv2.ApisixConsumerAuthParameter{ JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ Value: &apisixv2.ApisixConsumerJwtAuthValue{ Key: "my-key", @@ -240,7 +240,7 @@ func TestApisixConsumer_JwtAuth_AsymmetricRS256WithoutAnyKey(t *testing.T) { v := loadApisixConsumerSchema(t) ac := &apisixv2.ApisixConsumer{ Spec: apisixv2.ApisixConsumerSpec{ - AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + AuthParameter: &apisixv2.ApisixConsumerAuthParameter{ JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ Value: &apisixv2.ApisixConsumerJwtAuthValue{ Key: "my-key", @@ -259,7 +259,7 @@ func TestApisixConsumer_JwtAuth_AsymmetricES256WithoutAnyKey(t *testing.T) { v := loadApisixConsumerSchema(t) ac := &apisixv2.ApisixConsumer{ Spec: apisixv2.ApisixConsumerSpec{ - AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + AuthParameter: &apisixv2.ApisixConsumerAuthParameter{ JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ Value: &apisixv2.ApisixConsumerJwtAuthValue{ Key: "my-key", @@ -278,7 +278,7 @@ func TestApisixConsumer_JwtAuth_AsymmetricEdDSAWithoutAnyKey(t *testing.T) { v := loadApisixConsumerSchema(t) ac := &apisixv2.ApisixConsumer{ Spec: apisixv2.ApisixConsumerSpec{ - AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + AuthParameter: &apisixv2.ApisixConsumerAuthParameter{ JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ Value: &apisixv2.ApisixConsumerJwtAuthValue{ Key: "my-key", @@ -297,7 +297,7 @@ func TestApisixConsumer_JwtAuth_AsymmetricWithEmptyPublicKey(t *testing.T) { v := loadApisixConsumerSchema(t) ac := &apisixv2.ApisixConsumer{ Spec: apisixv2.ApisixConsumerSpec{ - AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + AuthParameter: &apisixv2.ApisixConsumerAuthParameter{ JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ Value: &apisixv2.ApisixConsumerJwtAuthValue{ Key: "my-key", @@ -321,7 +321,7 @@ func TestApisixConsumer_JwtAuth_EmptyAlgorithmTreatedAsSymmetric(t *testing.T) { v := loadApisixConsumerSchema(t) ac := &apisixv2.ApisixConsumer{ Spec: apisixv2.ApisixConsumerSpec{ - AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + AuthParameter: &apisixv2.ApisixConsumerAuthParameter{ JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ Value: &apisixv2.ApisixConsumerJwtAuthValue{ Key: "my-key", diff --git a/api/v2/zz_generated.deepcopy.go b/api/v2/zz_generated.deepcopy.go index 06675b10..8e659cd9 100644 --- a/api/v2/zz_generated.deepcopy.go +++ b/api/v2/zz_generated.deepcopy.go @@ -1,20 +1,19 @@ //go:build !ignore_autogenerated -/* -Copyright 2024. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. // Code generated by controller-gen. DO NOT EDIT. @@ -407,7 +406,18 @@ func (in *ApisixConsumerList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ApisixConsumerSpec) DeepCopyInto(out *ApisixConsumerSpec) { *out = *in - in.AuthParameter.DeepCopyInto(&out.AuthParameter) + if in.AuthParameter != nil { + in, out := &in.AuthParameter, &out.AuthParameter + *out = new(ApisixConsumerAuthParameter) + (*in).DeepCopyInto(*out) + } + if in.Plugins != nil { + in, out := &in.Plugins, &out.Plugins + *out = make([]ApisixRoutePlugin, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApisixConsumerSpec. diff --git a/config/crd/bases/apisix.apache.org_apisixconsumers.yaml b/config/crd/bases/apisix.apache.org_apisixconsumers.yaml index db0ec861..4b004137 100644 --- a/config/crd/bases/apisix.apache.org_apisixconsumers.yaml +++ b/config/crd/bases/apisix.apache.org_apisixconsumers.yaml @@ -319,8 +319,32 @@ spec: IngressClassName is the name of an IngressClass cluster resource. The controller uses this field to decide whether the resource should be managed. type: string - required: - - authParameter + plugins: + description: |- + Plugins lists additional consumer-scoped plugins to attach to this consumer. + These plugins are applied alongside any authentication plugin derived from AuthParameter. + An enabled plugin with the same name as the auth plugin derived from AuthParameter takes precedence. + items: + description: ApisixRoutePlugin represents an APISIX plugin. + properties: + config: + description: Plugin configuration. + x-kubernetes-preserve-unknown-fields: true + enable: + default: true + description: Whether this plugin is in use, default is true. + type: boolean + name: + description: The plugin name. + type: string + secretRef: + description: Plugin configuration secretRef. + type: string + required: + - enable + - name + type: object + type: array type: object status: description: ApisixStatus is the status report for Apisix ingress Resources diff --git a/docs/en/latest/reference/api-reference.md b/docs/en/latest/reference/api-reference.md index 9482e1d3..6d6f9595 100644 --- a/docs/en/latest/reference/api-reference.md +++ b/docs/en/latest/reference/api-reference.md @@ -780,7 +780,7 @@ _Appears in:_ #### ApisixConsumerJwtAuthValue -ApisixConsumerJwtAuthValue defines configuration for JWT authentication. +ApisixConsumerJwtAuthValue defines configuration for JWT authentication.

For asymmetric algorithms (RS*, ES*, PS*, EdDSA), at least one of public_key or private_key must be provided. Symmetric algorithms (HS256, HS384, HS512) and unset algorithm do not require any key field. @@ -875,6 +875,7 @@ ApisixConsumerSpec defines the desired state of ApisixConsumer. | --- | --- | | `ingressClassName` _string_ | IngressClassName is the name of an IngressClass cluster resource. The controller uses this field to decide whether the resource should be managed. | | `authParameter` _[ApisixConsumerAuthParameter](#apisixconsumerauthparameter)_ | AuthParameter defines the authentication credentials and configuration for this consumer. | +| `plugins` _[ApisixRoutePlugin](#apisixrouteplugin) array_ | Plugins lists additional consumer-scoped plugins to attach to this consumer. These plugins are applied alongside any authentication plugin derived from AuthParameter. An enabled plugin with the same name as the auth plugin derived from AuthParameter takes precedence. | _Appears in:_ @@ -1163,6 +1164,7 @@ ApisixRoutePlugin represents an APISIX plugin. _Appears in:_ +- [ApisixConsumerSpec](#apisixconsumerspec) - [ApisixGlobalRuleSpec](#apisixglobalrulespec) - [ApisixPluginConfigSpec](#apisixpluginconfigspec) - [ApisixRouteHTTP](#apisixroutehttp) diff --git a/internal/adc/translator/apisixconsumer.go b/internal/adc/translator/apisixconsumer.go index 406f1c2c..beee9ae4 100644 --- a/internal/adc/translator/apisixconsumer.go +++ b/internal/adc/translator/apisixconsumer.go @@ -55,42 +55,54 @@ const ( func (t *Translator) TranslateApisixConsumer(tctx *provider.TranslateContext, ac *v2.ApisixConsumer) (*TranslateResult, error) { result := &TranslateResult{} plugins := make(adctypes.Plugins) - if ac.Spec.AuthParameter.KeyAuth != nil { - cfg, err := t.translateConsumerKeyAuthPlugin(tctx, ac.Namespace, ac.Spec.AuthParameter.KeyAuth) - if err != nil { - return nil, fmt.Errorf("invalid key auth config: %s", err) + if ap := ac.Spec.AuthParameter; ap != nil { + if ap.KeyAuth != nil { + cfg, err := t.translateConsumerKeyAuthPlugin(tctx, ac.Namespace, ap.KeyAuth) + if err != nil { + return nil, fmt.Errorf("invalid key auth config: %s", err) + } + plugins["key-auth"] = cfg + } else if ap.BasicAuth != nil { + cfg, err := t.translateConsumerBasicAuthPlugin(tctx, ac.Namespace, ap.BasicAuth) + if err != nil { + return nil, fmt.Errorf("invalid basic auth config: %s", err) + } + plugins["basic-auth"] = cfg + } else if ap.JwtAuth != nil { + cfg, err := t.translateConsumerJwtAuthPlugin(tctx, ac.Namespace, ap.JwtAuth) + if err != nil { + return nil, fmt.Errorf("invalid jwt auth config: %s", err) + } + plugins["jwt-auth"] = cfg + } else if ap.WolfRBAC != nil { + cfg, err := t.translateConsumerWolfRBACPlugin(tctx, ac.Namespace, ap.WolfRBAC) + if err != nil { + return nil, fmt.Errorf("invalid wolf rbac config: %s", err) + } + plugins["wolf-rbac"] = cfg + } else if ap.HMACAuth != nil { + cfg, err := t.translateConsumerHMACAuthPlugin(tctx, ac.Namespace, ap.HMACAuth) + if err != nil { + return nil, fmt.Errorf("invalid hmac auth config: %s", err) + } + plugins["hmac-auth"] = cfg + } else if ap.LDAPAuth != nil { + cfg, err := t.translateConsumerLDAPAuthPlugin(tctx, ac.Namespace, ap.LDAPAuth) + if err != nil { + return nil, fmt.Errorf("invalid ldap auth config: %s", err) + } + plugins["ldap-auth"] = cfg } - plugins["key-auth"] = cfg - } else if ac.Spec.AuthParameter.BasicAuth != nil { - cfg, err := t.translateConsumerBasicAuthPlugin(tctx, ac.Namespace, ac.Spec.AuthParameter.BasicAuth) - if err != nil { - return nil, fmt.Errorf("invalid basic auth config: %s", err) - } - plugins["basic-auth"] = cfg - } else if ac.Spec.AuthParameter.JwtAuth != nil { - cfg, err := t.translateConsumerJwtAuthPlugin(tctx, ac.Namespace, ac.Spec.AuthParameter.JwtAuth) - if err != nil { - return nil, fmt.Errorf("invalid jwt auth config: %s", err) - } - plugins["jwt-auth"] = cfg - } else if ac.Spec.AuthParameter.WolfRBAC != nil { - cfg, err := t.translateConsumerWolfRBACPlugin(tctx, ac.Namespace, ac.Spec.AuthParameter.WolfRBAC) - if err != nil { - return nil, fmt.Errorf("invalid wolf rbac config: %s", err) - } - plugins["wolf-rbac"] = cfg - } else if ac.Spec.AuthParameter.HMACAuth != nil { - cfg, err := t.translateConsumerHMACAuthPlugin(tctx, ac.Namespace, ac.Spec.AuthParameter.HMACAuth) - if err != nil { - return nil, fmt.Errorf("invalid hmac auth config: %s", err) - } - plugins["hmac-auth"] = cfg - } else if ac.Spec.AuthParameter.LDAPAuth != nil { - cfg, err := t.translateConsumerLDAPAuthPlugin(tctx, ac.Namespace, ac.Spec.AuthParameter.LDAPAuth) - if err != nil { - return nil, fmt.Errorf("invalid ldap auth config: %s", err) + } + + // Merge generic consumer-scoped plugins. Only enabled entries are merged; + // an enabled plugin with the same name as an auth plugin derived from authParameter takes precedence. + for _, plugin := range ac.Spec.Plugins { + if !plugin.Enable { + continue } - plugins["ldap-auth"] = cfg + config := t.buildPluginConfig(plugin, ac.Namespace, tctx.Secrets) + plugins[plugin.Name] = config } username := adctypes.ComposeConsumerName(ac.Namespace, ac.Name) @@ -107,7 +119,9 @@ func (t *Translator) translateConsumerKeyAuthPlugin(tctx *provider.TranslateCont if cfg.Value != nil { return &adctypes.KeyAuthConsumerConfig{Key: cfg.Value.Key}, nil } - + if cfg.SecretRef == nil { + return nil, fmt.Errorf("key-auth: either value or secretRef must be specified") + } sec := tctx.Secrets[k8stypes.NamespacedName{ Namespace: consumerNamespace, Name: cfg.SecretRef.Name, @@ -129,7 +143,9 @@ func (t *Translator) translateConsumerBasicAuthPlugin(tctx *provider.TranslateCo Password: cfg.Value.Password, }, nil } - + if cfg.SecretRef == nil { + return nil, fmt.Errorf("basic-auth: either value or secretRef must be specified") + } sec := tctx.Secrets[k8stypes.NamespacedName{ Namespace: consumerNamespace, Name: cfg.SecretRef.Name, @@ -159,6 +175,9 @@ func (t *Translator) translateConsumerWolfRBACPlugin(tctx *provider.TranslateCon HeaderPrefix: cfg.Value.HeaderPrefix, }, nil } + if cfg.SecretRef == nil { + return nil, fmt.Errorf("wolf-rbac: either value or secretRef must be specified") + } sec := tctx.Secrets[k8stypes.NamespacedName{ Namespace: consumerNamespace, Name: cfg.SecretRef.Name, @@ -194,6 +213,9 @@ func (t *Translator) translateConsumerJwtAuthPlugin(tctx *provider.TranslateCont }, nil } + if cfg.SecretRef == nil { + return nil, fmt.Errorf("jwt-auth: either value or secretRef must be specified") + } sec := tctx.Secrets[k8stypes.NamespacedName{ Namespace: consumerNamespace, Name: cfg.SecretRef.Name, @@ -251,6 +273,9 @@ func (t *Translator) translateConsumerHMACAuthPlugin(tctx *provider.TranslateCon }, nil } + if cfg.SecretRef == nil { + return nil, fmt.Errorf("hmac-auth: either value or secretRef must be specified") + } sec := tctx.Secrets[k8stypes.NamespacedName{ Namespace: consumerNamespace, Name: cfg.SecretRef.Name, @@ -357,6 +382,9 @@ func (t *Translator) translateConsumerLDAPAuthPlugin(tctx *provider.TranslateCon }, nil } + if cfg.SecretRef == nil { + return nil, fmt.Errorf("ldap-auth: either value or secretRef must be specified") + } sec := tctx.Secrets[k8stypes.NamespacedName{ Namespace: consumerNamespace, Name: cfg.SecretRef.Name, diff --git a/internal/controller/apisixconsumer_controller.go b/internal/controller/apisixconsumer_controller.go index 35c580aa..5e471bd1 100644 --- a/internal/controller/apisixconsumer_controller.go +++ b/internal/controller/apisixconsumer_controller.go @@ -201,39 +201,62 @@ func (r *ApisixConsumerReconciler) listApisixConsumerForSecret(ctx context.Conte func (r *ApisixConsumerReconciler) processSpec(ctx context.Context, tctx *provider.TranslateContext, ac *apiv2.ApisixConsumer) error { var secretRef *corev1.LocalObjectReference - if ac.Spec.AuthParameter.KeyAuth != nil { - secretRef = ac.Spec.AuthParameter.KeyAuth.SecretRef - } else if ac.Spec.AuthParameter.BasicAuth != nil { - secretRef = ac.Spec.AuthParameter.BasicAuth.SecretRef - } else if ac.Spec.AuthParameter.JwtAuth != nil { - secretRef = ac.Spec.AuthParameter.JwtAuth.SecretRef - } else if ac.Spec.AuthParameter.WolfRBAC != nil { - secretRef = ac.Spec.AuthParameter.WolfRBAC.SecretRef - } else if ac.Spec.AuthParameter.HMACAuth != nil { - secretRef = ac.Spec.AuthParameter.HMACAuth.SecretRef - } else if ac.Spec.AuthParameter.LDAPAuth != nil { - secretRef = ac.Spec.AuthParameter.LDAPAuth.SecretRef - } - if secretRef == nil { - return nil + if ap := ac.Spec.AuthParameter; ap != nil { + if ap.KeyAuth != nil { + secretRef = ap.KeyAuth.SecretRef + } else if ap.BasicAuth != nil { + secretRef = ap.BasicAuth.SecretRef + } else if ap.JwtAuth != nil { + secretRef = ap.JwtAuth.SecretRef + } else if ap.WolfRBAC != nil { + secretRef = ap.WolfRBAC.SecretRef + } else if ap.HMACAuth != nil { + secretRef = ap.HMACAuth.SecretRef + } else if ap.LDAPAuth != nil { + secretRef = ap.LDAPAuth.SecretRef + } } - - namespacedName := types.NamespacedName{ - Name: secretRef.Name, - Namespace: ac.Namespace, + if secretRef != nil && secretRef.Name != "" { + namespacedName := types.NamespacedName{ + Name: secretRef.Name, + Namespace: ac.Namespace, + } + secret := &corev1.Secret{} + if err := r.Get(ctx, namespacedName, secret); err != nil { + if k8serrors.IsNotFound(err) { + r.Log.Info("secret not found", "secret", namespacedName) + } else { + r.Log.Error(err, "failed to get secret", "secret", namespacedName) + return err + } + } else { + tctx.Secrets[namespacedName] = secret + } } - secret := &corev1.Secret{} - if err := r.Get(ctx, namespacedName, secret); err != nil { - if k8serrors.IsNotFound(err) { - r.Log.Info("secret not found", "secret", namespacedName) - return nil + for _, plugin := range ac.Spec.Plugins { + if !plugin.Enable || plugin.SecretRef == "" { + continue + } + namespacedName := types.NamespacedName{ + Name: plugin.SecretRef, + Namespace: ac.Namespace, + } + if _, loaded := tctx.Secrets[namespacedName]; loaded { + continue + } + secret := &corev1.Secret{} + if err := r.Get(ctx, namespacedName, secret); err != nil { + if k8serrors.IsNotFound(err) { + r.Log.Info("secret not found for plugin", "plugin", plugin.Name, "secret", namespacedName) + } else { + r.Log.Error(err, "failed to get secret for plugin", "plugin", plugin.Name, "secret", namespacedName) + return err + } } else { - r.Log.Error(err, "failed to get secret", "secret", namespacedName) - return err + tctx.Secrets[namespacedName] = secret } } - tctx.Secrets[namespacedName] = secret return nil } diff --git a/internal/controller/indexer/indexer.go b/internal/controller/indexer/indexer.go index 6ee1c4ef..5d4b4998 100644 --- a/internal/controller/indexer/indexer.go +++ b/internal/controller/indexer/indexer.go @@ -923,22 +923,29 @@ func ApisixPluginConfigSecretIndexFunc(obj client.Object) (keys []string) { func ApisixConsumerSecretIndexFunc(rawObj client.Object) (keys []string) { ac := rawObj.(*apiv2.ApisixConsumer) var secretRef *corev1.LocalObjectReference - if ac.Spec.AuthParameter.KeyAuth != nil { - secretRef = ac.Spec.AuthParameter.KeyAuth.SecretRef - } else if ac.Spec.AuthParameter.BasicAuth != nil { - secretRef = ac.Spec.AuthParameter.BasicAuth.SecretRef - } else if ac.Spec.AuthParameter.JwtAuth != nil { - secretRef = ac.Spec.AuthParameter.JwtAuth.SecretRef - } else if ac.Spec.AuthParameter.WolfRBAC != nil { - secretRef = ac.Spec.AuthParameter.WolfRBAC.SecretRef - } else if ac.Spec.AuthParameter.HMACAuth != nil { - secretRef = ac.Spec.AuthParameter.HMACAuth.SecretRef - } else if ac.Spec.AuthParameter.LDAPAuth != nil { - secretRef = ac.Spec.AuthParameter.LDAPAuth.SecretRef - } - if secretRef != nil { + if ap := ac.Spec.AuthParameter; ap != nil { + if ap.KeyAuth != nil { + secretRef = ap.KeyAuth.SecretRef + } else if ap.BasicAuth != nil { + secretRef = ap.BasicAuth.SecretRef + } else if ap.JwtAuth != nil { + secretRef = ap.JwtAuth.SecretRef + } else if ap.WolfRBAC != nil { + secretRef = ap.WolfRBAC.SecretRef + } else if ap.HMACAuth != nil { + secretRef = ap.HMACAuth.SecretRef + } else if ap.LDAPAuth != nil { + secretRef = ap.LDAPAuth.SecretRef + } + } + if secretRef != nil && secretRef.Name != "" { keys = append(keys, GenIndexKey(ac.GetNamespace(), secretRef.Name)) } + for _, plugin := range ac.Spec.Plugins { + if plugin.Enable && plugin.SecretRef != "" { + keys = append(keys, GenIndexKey(ac.GetNamespace(), plugin.SecretRef)) + } + } return } diff --git a/internal/webhook/v1/apisixconsumer_webhook.go b/internal/webhook/v1/apisixconsumer_webhook.go index 35c5c3cd..57c704f9 100644 --- a/internal/webhook/v1/apisixconsumer_webhook.go +++ b/internal/webhook/v1/apisixconsumer_webhook.go @@ -129,23 +129,25 @@ func (v *ApisixConsumerCustomValidator) collectWarnings(ctx context.Context, con } params := consumer.Spec.AuthParameter - if params.BasicAuth != nil { - addSecretWarning(params.BasicAuth.SecretRef) - } - if params.KeyAuth != nil { - addSecretWarning(params.KeyAuth.SecretRef) - } - if params.WolfRBAC != nil { - addSecretWarning(params.WolfRBAC.SecretRef) - } - if params.JwtAuth != nil { - addSecretWarning(params.JwtAuth.SecretRef) - } - if params.HMACAuth != nil { - addSecretWarning(params.HMACAuth.SecretRef) - } - if params.LDAPAuth != nil { - addSecretWarning(params.LDAPAuth.SecretRef) + if params != nil { + if params.BasicAuth != nil { + addSecretWarning(params.BasicAuth.SecretRef) + } + if params.KeyAuth != nil { + addSecretWarning(params.KeyAuth.SecretRef) + } + if params.WolfRBAC != nil { + addSecretWarning(params.WolfRBAC.SecretRef) + } + if params.JwtAuth != nil { + addSecretWarning(params.JwtAuth.SecretRef) + } + if params.HMACAuth != nil { + addSecretWarning(params.HMACAuth.SecretRef) + } + if params.LDAPAuth != nil { + addSecretWarning(params.LDAPAuth.SecretRef) + } } return warnings diff --git a/internal/webhook/v1/apisixconsumer_webhook_test.go b/internal/webhook/v1/apisixconsumer_webhook_test.go index e1be420d..b1e0f5c7 100644 --- a/internal/webhook/v1/apisixconsumer_webhook_test.go +++ b/internal/webhook/v1/apisixconsumer_webhook_test.go @@ -80,7 +80,7 @@ func TestApisixConsumerValidator_MissingBasicAuthSecret(t *testing.T) { }, Spec: apisixv2.ApisixConsumerSpec{ IngressClassName: "apisix", - AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + AuthParameter: &apisixv2.ApisixConsumerAuthParameter{ BasicAuth: &apisixv2.ApisixConsumerBasicAuth{ SecretRef: &corev1.LocalObjectReference{Name: "basic-auth"}, }, @@ -104,7 +104,7 @@ func TestApisixConsumerValidator_MultipleSecretWarnings(t *testing.T) { }, Spec: apisixv2.ApisixConsumerSpec{ IngressClassName: "apisix", - AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + AuthParameter: &apisixv2.ApisixConsumerAuthParameter{ BasicAuth: &apisixv2.ApisixConsumerBasicAuth{ SecretRef: &corev1.LocalObjectReference{Name: "basic-auth"}, }, @@ -144,7 +144,7 @@ func TestApisixConsumerValidator_NoWarningsWhenSecretsExist(t *testing.T) { }, Spec: apisixv2.ApisixConsumerSpec{ IngressClassName: "apisix", - AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + AuthParameter: &apisixv2.ApisixConsumerAuthParameter{ KeyAuth: &apisixv2.ApisixConsumerKeyAuth{ SecretRef: &corev1.LocalObjectReference{Name: "key-auth"}, }, @@ -181,7 +181,7 @@ func TestApisixConsumerValidator_DeniesOnADCValidationFailure(t *testing.T) { }, Spec: apisixv2.ApisixConsumerSpec{ IngressClassName: "apisix", - AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + AuthParameter: &apisixv2.ApisixConsumerAuthParameter{ KeyAuth: &apisixv2.ApisixConsumerKeyAuth{ SecretRef: &corev1.LocalObjectReference{Name: "key-auth"}, }, @@ -220,7 +220,7 @@ func TestApisixConsumerValidator_UsesADCValidateEndpointForControlPlane(t *testi }, Spec: apisixv2.ApisixConsumerSpec{ IngressClassName: managedIngressClassName, - AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + AuthParameter: &apisixv2.ApisixConsumerAuthParameter{ KeyAuth: &apisixv2.ApisixConsumerKeyAuth{ Value: &apisixv2.ApisixConsumerKeyAuthValue{Key: "shared-key"}, }, diff --git a/test/e2e/crds/v2/consumer.go b/test/e2e/crds/v2/consumer.go index 32f08d4f..3b8eb2e7 100644 --- a/test/e2e/crds/v2/consumer.go +++ b/test/e2e/crds/v2/consumer.go @@ -675,4 +675,190 @@ spec: Eventually(request).WithArguments("/get", "jack", "jackPassword").WithTimeout(5 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) }) }) + + Context("Test Consumer Plugins - authParameter with extra plugins", func() { + // Verify that a consumer with authParameter + plugins (e.g. limit-count) works: + // auth is enforced via authParameter and limit-count throttles authenticated traffic. + const ( + consumerWithPlugins = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixConsumer +metadata: + name: consumer-with-plugins +spec: + ingressClassName: %s + authParameter: + keyAuth: + value: + key: plugin-test-key + plugins: + - name: limit-count + enable: true + config: + count: 2 + time_window: 60 + rejected_code: 429 + key: consumer_name + policy: local +` + pluginRoute = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: plugin-route +spec: + ingressClassName: %s + http: + - name: rule0 + match: + hosts: + - httpbin + paths: + - /get + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + authentication: + enable: true + type: keyAuth +` + ) + + It("consumer-level limit-count plugin is enforced", func() { + By("apply ApisixRoute") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "plugin-route"}, + &apiv2.ApisixRoute{}, fmt.Sprintf(pluginRoute, s.Namespace())) + + By("apply ApisixConsumer with plugins") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "consumer-with-plugins"}, + &apiv2.ApisixConsumer{}, fmt.Sprintf(consumerWithPlugins, s.Namespace())) + + By("unauthenticated request is rejected") + s.RequestAssert(&scaffold.RequestAssert{ + Method: "GET", + Path: "/get", + Host: "httpbin", + Headers: map[string]string{"apikey": "wrong-key"}, + Check: scaffold.WithExpectedStatus(http.StatusUnauthorized), + }) + + By("first authenticated request succeeds") + s.RequestAssert(&scaffold.RequestAssert{ + Method: "GET", + Path: "/get", + Host: "httpbin", + Headers: map[string]string{"apikey": "plugin-test-key"}, + Check: scaffold.WithExpectedStatus(http.StatusOK), + }) + + By("second authenticated request succeeds") + s.RequestAssert(&scaffold.RequestAssert{ + Method: "GET", + Path: "/get", + Host: "httpbin", + Headers: map[string]string{"apikey": "plugin-test-key"}, + Check: scaffold.WithExpectedStatus(http.StatusOK), + }) + + By("third request is rate-limited by consumer-level limit-count") + s.RequestAssert(&scaffold.RequestAssert{ + Method: "GET", + Path: "/get", + Host: "httpbin", + Headers: map[string]string{"apikey": "plugin-test-key"}, + Check: scaffold.WithExpectedStatus(http.StatusTooManyRequests), + }) + + By("delete ApisixConsumer") + err := s.DeleteResource("ApisixConsumer", "consumer-with-plugins") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixConsumer") + + By("delete ApisixRoute") + err = s.DeleteResource("ApisixRoute", "plugin-route") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + }) + }) + + Context("Test Consumer Plugins - plugins only (no authParameter)", func() { + // Verify that authParameter can be omitted entirely and auth can be + // configured directly via the plugins field. + const ( + consumerPluginsOnly = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixConsumer +metadata: + name: consumer-plugins-only +spec: + ingressClassName: %s + plugins: + - name: key-auth + enable: true + config: + key: plugins-only-key +` + pluginsOnlyRoute = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: plugins-only-route +spec: + ingressClassName: %s + http: + - name: rule0 + match: + hosts: + - httpbin + paths: + - /get + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + authentication: + enable: true + type: keyAuth +` + ) + + It("auth plugin configured via plugins field only", func() { + By("apply ApisixRoute") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "plugins-only-route"}, + &apiv2.ApisixRoute{}, fmt.Sprintf(pluginsOnlyRoute, s.Namespace())) + + By("apply ApisixConsumer with plugins only (no authParameter)") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "consumer-plugins-only"}, + &apiv2.ApisixConsumer{}, fmt.Sprintf(consumerPluginsOnly, s.Namespace())) + + By("request with wrong key is rejected") + Eventually(func() int { + return s.NewAPISIXClient().GET("/get"). + WithHeader("apikey", "wrong-key"). + WithHost("httpbin"). + Expect().Raw().StatusCode + }).WithTimeout(10 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusUnauthorized)) + + By("request with correct key succeeds") + Eventually(func() int { + return s.NewAPISIXClient().GET("/get"). + WithHeader("apikey", "plugins-only-key"). + WithHost("httpbin"). + Expect().Raw().StatusCode + }).WithTimeout(10 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("delete ApisixConsumer") + err := s.DeleteResource("ApisixConsumer", "consumer-plugins-only") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixConsumer") + + By("request with correct key is rejected after consumer deletion") + Eventually(func() int { + return s.NewAPISIXClient().GET("/get"). + WithHeader("apikey", "plugins-only-key"). + WithHost("httpbin"). + Expect().Raw().StatusCode + }).WithTimeout(10 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusUnauthorized)) + + By("delete ApisixRoute") + err = s.DeleteResource("ApisixRoute", "plugins-only-route") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + }) + }) })