From 6b650d05a42ead46d188acc598b09acb8c2db41b Mon Sep 17 00:00:00 2001 From: svvac <_@svvac.net> Date: Mon, 13 Dec 2021 19:31:19 +0100 Subject: [PATCH 1/6] add support for HTTP basic authentication policy --- .../common/crds/k8s.nginx.org_policies.yaml | 8 + .../crds/k8s.nginx.org_policies.yaml | 8 + internal/configs/configurator.go | 11 + internal/configs/version1/config.go | 8 + internal/configs/version2/http.go | 8 + .../version2/nginx-plus.virtualserver.tmpl | 10 + .../configs/version2/nginx.virtualserver.tmpl | 10 + internal/configs/virtualserver.go | 40 ++++ internal/configs/virtualserver_test.go | 189 ++++++++++++++++++ internal/k8s/controller.go | 34 ++++ internal/k8s/controller_test.go | 171 +++++++++++++++- internal/k8s/secrets/validation.go | 27 ++- internal/k8s/secrets/validation_test.go | 85 ++++++++ internal/nginx/manager.go | 5 +- pkg/apis/configuration/v1/types.go | 8 + .../configuration/v1/zz_generated.deepcopy.go | 21 ++ pkg/apis/configuration/validation/policy.go | 44 ++-- 17 files changed, 672 insertions(+), 15 deletions(-) diff --git a/deployments/common/crds/k8s.nginx.org_policies.yaml b/deployments/common/crds/k8s.nginx.org_policies.yaml index 6d6df900f1..5a31f54bd4 100644 --- a/deployments/common/crds/k8s.nginx.org_policies.yaml +++ b/deployments/common/crds/k8s.nginx.org_policies.yaml @@ -54,6 +54,14 @@ spec: type: array items: type: string + basicAuth: + description: 'BasicAuth holds HTTP Basic authentication configuration policy status: preview' + type: object + properties: + realm: + type: string + secret: + type: string egressMTLS: description: EgressMTLS defines an Egress MTLS policy. type: object diff --git a/deployments/helm-chart/crds/k8s.nginx.org_policies.yaml b/deployments/helm-chart/crds/k8s.nginx.org_policies.yaml index 6d6df900f1..5a31f54bd4 100644 --- a/deployments/helm-chart/crds/k8s.nginx.org_policies.yaml +++ b/deployments/helm-chart/crds/k8s.nginx.org_policies.yaml @@ -54,6 +54,14 @@ spec: type: array items: type: string + basicAuth: + description: 'BasicAuth holds HTTP Basic authentication configuration policy status: preview' + type: object + properties: + realm: + type: string + secret: + type: string egressMTLS: description: EgressMTLS defines an Egress MTLS policy. type: object diff --git a/internal/configs/configurator.go b/internal/configs/configurator.go index fbe7a3322d..14d7bec37c 100644 --- a/internal/configs/configurator.go +++ b/internal/configs/configurator.go @@ -52,6 +52,9 @@ const WildcardSecretName = "wildcard" // JWTKeyKey is the key of the data field of a Secret where the JWK must be stored. const JWTKeyKey = "jwk" +// HtpasswdFileKey is the key of the data field of a Secret where the HTTP basic authorization list must be stored +const HtpasswdFileKey = "htpasswd" + // CAKey is the key of the data field of a Secret where the cert must be stored. const CAKey = "ca.crt" @@ -650,6 +653,12 @@ func (cnf *Configurator) addOrUpdateJWKSecret(secret *api_v1.Secret) string { return cnf.nginxManager.CreateSecret(name, data, nginx.JWKSecretFileMode) } +func (cnf *Configurator) addOrUpdateHtpasswdSecret(secret *api_v1.Secret) string { + name := objectMetaToFileName(&secret.ObjectMeta) + data := secret.Data[HtpasswdFileKey] + return cnf.nginxManager.CreateSecret(name, data, nginx.HtpasswdSecretFileMode) +} + // AddOrUpdateResources adds or updates configuration for resources. func (cnf *Configurator) AddOrUpdateResources(resources ExtendedResources) (Warnings, error) { allWarnings := newWarnings() @@ -1496,6 +1505,8 @@ func (cnf *Configurator) AddOrUpdateSecret(secret *api_v1.Secret) string { return cnf.addOrUpdateCASecret(secret) case secrets.SecretTypeJWK: return cnf.addOrUpdateJWKSecret(secret) + case secrets.SecretTypeHtpasswd: + return cnf.addOrUpdateHtpasswdSecret(secret) case secrets.SecretTypeOIDC: // OIDC ClientSecret is not required on the filesystem, it is written directly to the config file. return "" diff --git a/internal/configs/version1/config.go b/internal/configs/version1/config.go index cb6a63b182..d3f9b47e12 100644 --- a/internal/configs/version1/config.go +++ b/internal/configs/version1/config.go @@ -90,6 +90,7 @@ type Server struct { RealIPRecursive bool JWTAuth *JWTAuth + BasicAuth *BasicAuth JWTRedirectLocations []JWTRedirectLocation Ports []int @@ -117,6 +118,12 @@ type JWTRedirectLocation struct { LoginURL string } +// BasicAuth holds HTTP Basic authentication parameters +type BasicAuth struct { + Realm string + Secret string +} + // JWTAuth holds JWT authentication configuration. type JWTAuth struct { Key string @@ -144,6 +151,7 @@ type Location struct { ProxyMaxTempFileSize string ProxySSLName string JWTAuth *JWTAuth + BasicAuth *BasicAuth ServiceName string MinionIngress *Ingress diff --git a/internal/configs/version2/http.go b/internal/configs/version2/http.go index de830da1cf..ca410ee184 100644 --- a/internal/configs/version2/http.go +++ b/internal/configs/version2/http.go @@ -68,6 +68,7 @@ type Server struct { LimitReqOptions LimitReqOptions LimitReqs []LimitReq JWTAuth *JWTAuth + BasicAuth *BasicAuth IngressMTLS *IngressMTLS EgressMTLS *EgressMTLS OIDC *OIDC @@ -175,6 +176,7 @@ type Location struct { LimitReqOptions LimitReqOptions LimitReqs []LimitReq JWTAuth *JWTAuth + BasicAuth *BasicAuth EgressMTLS *EgressMTLS OIDC bool WAF *WAF @@ -351,3 +353,9 @@ type JWTAuth struct { Realm string Token string } + +// BasicAuth refers to basic HTTP authentication mechanism options +type BasicAuth struct { + Secret string + Realm string +} diff --git a/internal/configs/version2/nginx-plus.virtualserver.tmpl b/internal/configs/version2/nginx-plus.virtualserver.tmpl index 0d798d1f51..62fbe645b1 100644 --- a/internal/configs/version2/nginx-plus.virtualserver.tmpl +++ b/internal/configs/version2/nginx-plus.virtualserver.tmpl @@ -166,6 +166,11 @@ server { auth_jwt_key_file {{ .Secret }}; {{ end }} + {{ with $s.BasicAuth }} + auth_basic {{ printf "%q" .Realm }}; + auth_basic_user_file {{ .Secret }}; + {{ end }} + {{ with $s.EgressMTLS }} {{ if .Certificate }} proxy_ssl_certificate {{ .Certificate }}; @@ -338,6 +343,11 @@ server { auth_jwt_key_file {{ .Secret }}; {{ end }} + {{ with $l.BasicAuth }} + auth_basic {{ printf "%q" .Realm }}; + auth_basic_user_file {{ .Secret }}; + {{ end }} + {{ $proxyOrGRPC := "proxy" }}{{ if $l.GRPCPass }}{{ $proxyOrGRPC = "grpc" }}{{ end }} {{ with $l.EgressMTLS }} diff --git a/internal/configs/version2/nginx.virtualserver.tmpl b/internal/configs/version2/nginx.virtualserver.tmpl index dc9c95db59..692e25686a 100644 --- a/internal/configs/version2/nginx.virtualserver.tmpl +++ b/internal/configs/version2/nginx.virtualserver.tmpl @@ -127,6 +127,11 @@ server { {{ if $rl.Delay }} delay={{ $rl.Delay }}{{ end }}{{ if $rl.NoDelay }} nodelay{{ end }}; {{ end }} + {{ with $s.BasicAuth }} + auth_basic {{ printf "%q" .Realm }}; + auth_basic_user_file {{ .Secret }}; + {{ end }} + {{ with $s.EgressMTLS }} {{ if .Certificate }} proxy_ssl_certificate {{ .Certificate }}; @@ -226,6 +231,11 @@ server { {{ if $rl.Delay }} delay={{ $rl.Delay }}{{ end }}{{ if $rl.NoDelay }} nodelay{{ end }}; {{ end }} + {{ with $l.BasicAuth }} + auth_basic {{ printf "%q" .Realm }}; + auth_basic_user_file {{ .Secret }}; + {{ end }} + {{ $proxyOrGRPC := "proxy" }}{{ if $l.GRPCPass }}{{ $proxyOrGRPC = "grpc" }}{{ end }} {{ with $l.EgressMTLS }} diff --git a/internal/configs/virtualserver.go b/internal/configs/virtualserver.go index ae8b94f571..bece3e1353 100644 --- a/internal/configs/virtualserver.go +++ b/internal/configs/virtualserver.go @@ -666,6 +666,7 @@ func (vsc *virtualServerConfigurator) GenerateVirtualServerConfig( LimitReqOptions: policiesCfg.LimitReqOptions, LimitReqs: policiesCfg.LimitReqs, JWTAuth: policiesCfg.JWTAuth, + BasicAuth: policiesCfg.BasicAuth, IngressMTLS: policiesCfg.IngressMTLS, EgressMTLS: policiesCfg.EgressMTLS, OIDC: vsc.oidcPolCfg.oidc, @@ -688,6 +689,7 @@ type policiesCfg struct { LimitReqZones []version2.LimitReqZone LimitReqs []version2.LimitReq JWTAuth *version2.JWTAuth + BasicAuth *version2.BasicAuth IngressMTLS *version2.IngressMTLS EgressMTLS *version2.EgressMTLS OIDC bool @@ -766,6 +768,41 @@ func (p *policiesCfg) addRateLimitConfig( return res } +func (p *policiesCfg) addBasicAuthConfig( + basicAuth *conf_v1.BasicAuth, + polKey string, + polNamespace string, + secretRefs map[string]*secrets.SecretReference, +) *validationResults { + res := newValidationResults() + if p.BasicAuth != nil { + res.addWarningf("Multiple basic auth policies in the same context is not valid. Basic auth policy %s will be ignored", polKey) + return res + } + + basicSecretKey := fmt.Sprintf("%v/%v", polNamespace, basicAuth.Secret) + secretRef := secretRefs[basicSecretKey] + var secretType api_v1.SecretType + if secretRef.Secret != nil { + secretType = secretRef.Secret.Type + } + if secretType != "" && secretType != secrets.SecretTypeHtpasswd { + res.addWarningf("Basic Auth policy %s references a secret %s of a wrong type '%s', must be '%s'", polKey, basicSecretKey, secretType, secrets.SecretTypeHtpasswd) + res.isError = true + return res + } else if secretRef.Error != nil { + res.addWarningf("Basic Auth policy %s references an invalid secret %s: %v", polKey, basicSecretKey, secretRef.Error) + res.isError = true + return res + } + + p.BasicAuth = &version2.BasicAuth{ + Secret: secretRef.Path, + Realm: basicAuth.Realm, + } + return res +} + func (p *policiesCfg) addJWTAuthConfig( jwtAuth *conf_v1.JWTAuth, polKey string, @@ -1115,6 +1152,8 @@ func (vsc *virtualServerConfigurator) generatePolicies( ) case pol.Spec.JWTAuth != nil: res = config.addJWTAuthConfig(pol.Spec.JWTAuth, key, polNamespace, policyOpts.secretRefs) + case pol.Spec.BasicAuth != nil: + res = config.addBasicAuthConfig(pol.Spec.BasicAuth, key, polNamespace, policyOpts.secretRefs) case pol.Spec.IngressMTLS != nil: res = config.addIngressMTLSConfig( pol.Spec.IngressMTLS, @@ -1207,6 +1246,7 @@ func addPoliciesCfgToLocation(cfg policiesCfg, location *version2.Location) { location.LimitReqOptions = cfg.LimitReqOptions location.LimitReqs = cfg.LimitReqs location.JWTAuth = cfg.JWTAuth + location.BasicAuth = cfg.BasicAuth location.EgressMTLS = cfg.EgressMTLS location.OIDC = cfg.OIDC location.WAF = cfg.WAF diff --git a/internal/configs/virtualserver_test.go b/internal/configs/virtualserver_test.go index 3df11917da..93fb9bb857 100644 --- a/internal/configs/virtualserver_test.go +++ b/internal/configs/virtualserver_test.go @@ -2584,6 +2584,12 @@ func TestGeneratePolicies(t *testing.T) { }, Path: "/etc/nginx/secrets/default-jwt-secret", }, + "default/htpasswd-secret": { + Secret: &api_v1.Secret{ + Type: secrets.SecretTypeHtpasswd, + }, + Path: "/etc/nginx/secrets/default-htpasswd-secret", + }, "default/oidc-secret": { Secret: &api_v1.Secret{ Type: secrets.SecretTypeOIDC, @@ -2812,6 +2818,35 @@ func TestGeneratePolicies(t *testing.T) { }, msg: "jwt reference", }, + { + policyRefs: []conf_v1.PolicyReference{ + { + Name: "basic-auth-policy", + Namespace: "default", + }, + }, + policies: map[string]*conf_v1.Policy{ + "default/basic-auth-policy": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "basic-auth-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + BasicAuth: &conf_v1.BasicAuth{ + Realm: "My Test API", + Secret: "htpasswd-secret", + }, + }, + }, + }, + expected: policiesCfg{ + BasicAuth: &version2.BasicAuth{ + Secret: "/etc/nginx/secrets/default-htpasswd-secret", + Realm: "My Test API", + }, + }, + msg: "basic auth reference", + }, { policyRefs: []conf_v1.PolicyReference{ { @@ -3270,6 +3305,160 @@ func TestGeneratePoliciesFails(t *testing.T) { expectedOidc: &oidcPolicyCfg{}, msg: "multi jwt reference", }, + { + policyRefs: []conf_v1.PolicyReference{ + { + Name: "basic-auth-policy", + Namespace: "default", + }, + }, + policies: map[string]*conf_v1.Policy{ + "default/basic-auth-policy": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "basic-auth-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + BasicAuth: &conf_v1.BasicAuth{ + Realm: "test", + Secret: "htpasswd-secret", + }, + }, + }, + }, + policyOpts: policyOptions{ + secretRefs: map[string]*secrets.SecretReference{ + "default/htpasswd-secret": { + Secret: &api_v1.Secret{ + Type: secrets.SecretTypeHtpasswd, + }, + Error: errors.New("secret is invalid"), + }, + }, + }, + expected: policiesCfg{ + ErrorReturn: &version2.Return{ + Code: 500, + }, + }, + expectedWarnings: Warnings{ + nil: { + `Basic Auth policy default/basic-auth-policy references an invalid secret default/htpasswd-secret: secret is invalid`, + }, + }, + expectedOidc: &oidcPolicyCfg{}, + msg: "basic auth reference missing secret", + }, + { + policyRefs: []conf_v1.PolicyReference{ + { + Name: "basic-auth-policy", + Namespace: "default", + }, + }, + policies: map[string]*conf_v1.Policy{ + "default/basic-auth-policy": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "basic-auth-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + BasicAuth: &conf_v1.BasicAuth{ + Realm: "test", + Secret: "htpasswd-secret", + }, + }, + }, + }, + policyOpts: policyOptions{ + secretRefs: map[string]*secrets.SecretReference{ + "default/htpasswd-secret": { + Secret: &api_v1.Secret{ + Type: secrets.SecretTypeCA, + }, + }, + }, + }, + expected: policiesCfg{ + ErrorReturn: &version2.Return{ + Code: 500, + }, + }, + expectedWarnings: Warnings{ + nil: { + `Basic Auth policy default/basic-auth-policy references a secret default/htpasswd-secret of a wrong type 'nginx.org/ca', must be 'nginx.org/htpasswd'`, + }, + }, + expectedOidc: &oidcPolicyCfg{}, + msg: "basic auth references wrong secret type", + }, + { + policyRefs: []conf_v1.PolicyReference{ + { + Name: "basic-auth-policy", + Namespace: "default", + }, + { + Name: "basic-auth-policy2", + Namespace: "default", + }, + }, + policies: map[string]*conf_v1.Policy{ + "default/basic-auth-policy": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "basic-auth-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + BasicAuth: &conf_v1.BasicAuth{ + Realm: "test", + Secret: "htpasswd-secret", + }, + }, + }, + "default/basic-auth-policy2": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "basic-auth-policy2", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + BasicAuth: &conf_v1.BasicAuth{ + Realm: "test", + Secret: "htpasswd-secret2", + }, + }, + }, + }, + policyOpts: policyOptions{ + secretRefs: map[string]*secrets.SecretReference{ + "default/htpasswd-secret": { + Secret: &api_v1.Secret{ + Type: secrets.SecretTypeHtpasswd, + }, + Path: "/etc/nginx/secrets/default-htpasswd-secret", + }, + "default/htpasswd-secret2": { + Secret: &api_v1.Secret{ + Type: secrets.SecretTypeHtpasswd, + }, + Path: "/etc/nginx/secrets/default-htpasswd-secret2", + }, + }, + }, + expected: policiesCfg{ + BasicAuth: &version2.BasicAuth{ + Secret: "/etc/nginx/secrets/default-htpasswd-secret", + Realm: "test", + }, + }, + expectedWarnings: Warnings{ + nil: { + `Multiple basic auth policies in the same context is not valid. Basic auth policy default/basic-auth-policy2 will be ignored`, + }, + }, + expectedOidc: &oidcPolicyCfg{}, + msg: "multi basic auth reference", + }, { policyRefs: []conf_v1.PolicyReference{ { diff --git a/internal/k8s/controller.go b/internal/k8s/controller.go index 92dc478e69..3b4fd6037b 100644 --- a/internal/k8s/controller.go +++ b/internal/k8s/controller.go @@ -2423,6 +2423,10 @@ func (lbc *LoadBalancerController) createVirtualServerEx(virtualServer *conf_v1. if err != nil { glog.Warningf("Error getting JWT secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) } + err = lbc.addBasicSecretRefs(virtualServerEx.SecretRefs, policies) + if err != nil { + glog.Warningf("Error getting Basic Auth secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) + } err = lbc.addIngressMTLSSecretRefs(virtualServerEx.SecretRefs, policies) if err != nil { glog.Warningf("Error getting IngressMTLS secret for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) @@ -2513,6 +2517,10 @@ func (lbc *LoadBalancerController) createVirtualServerEx(virtualServer *conf_v1. if err != nil { glog.Warningf("Error getting JWT secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) } + err = lbc.addBasicSecretRefs(virtualServerEx.SecretRefs, vsRoutePolicies) + if err != nil { + glog.Warningf("Error getting Basic Auth secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) + } err = lbc.addEgressMTLSSecretRefs(virtualServerEx.SecretRefs, vsRoutePolicies) if err != nil { glog.Warningf("Error getting EgressMTLS secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) @@ -2550,6 +2558,11 @@ func (lbc *LoadBalancerController) createVirtualServerEx(virtualServer *conf_v1. glog.Warningf("Error getting JWT secrets for VirtualServerRoute %v/%v: %v", vsr.Namespace, vsr.Name, err) } + err = lbc.addBasicSecretRefs(virtualServerEx.SecretRefs, vsrSubroutePolicies) + if err != nil { + glog.Warningf("Error getting Basic Auth secrets for VirtualServerRoute %v/%v: %v", vsr.Namespace, vsr.Name, err) + } + err = lbc.addEgressMTLSSecretRefs(virtualServerEx.SecretRefs, vsrSubroutePolicies) if err != nil { glog.Warningf("Error getting EgressMTLS secrets for VirtualServerRoute %v/%v: %v", vsr.Namespace, vsr.Name, err) @@ -2717,6 +2730,25 @@ func (lbc *LoadBalancerController) addJWTSecretRefs(secretRefs map[string]*secre return nil } +func (lbc *LoadBalancerController) addBasicSecretRefs(secretRefs map[string]*secrets.SecretReference, policies []*conf_v1.Policy) error { + for _, pol := range policies { + if pol.Spec.BasicAuth == nil { + continue + } + + secretKey := fmt.Sprintf("%v/%v", pol.Namespace, pol.Spec.BasicAuth.Secret) + secretRef := lbc.secretStore.GetSecret(secretKey) + + secretRefs[secretKey] = secretRef + + if secretRef.Error != nil { + return secretRef.Error + } + } + + return nil +} + func (lbc *LoadBalancerController) addIngressMTLSSecretRefs(secretRefs map[string]*secrets.SecretReference, policies []*conf_v1.Policy) error { for _, pol := range policies { if pol.Spec.IngressMTLS == nil { @@ -2852,6 +2884,8 @@ func findPoliciesForSecret(policies []*conf_v1.Policy, secretNamespace string, s res = append(res, pol) } else if pol.Spec.JWTAuth != nil && pol.Spec.JWTAuth.Secret == secretName && pol.Namespace == secretNamespace { res = append(res, pol) + } else if pol.Spec.BasicAuth != nil && pol.Spec.BasicAuth.Secret == secretName && pol.Namespace == secretNamespace { + res = append(res, pol) } else if pol.Spec.EgressMTLS != nil && pol.Spec.EgressMTLS.TLSSecret == secretName && pol.Namespace == secretNamespace { res = append(res, pol) } else if pol.Spec.EgressMTLS != nil && pol.Spec.EgressMTLS.TrustedCertSecret == secretName && pol.Namespace == secretNamespace { diff --git a/internal/k8s/controller_test.go b/internal/k8s/controller_test.go index 0531caa256..28ee0a8797 100644 --- a/internal/k8s/controller_test.go +++ b/internal/k8s/controller_test.go @@ -682,7 +682,7 @@ func TestGetPolicies(t *testing.T) { expectedPolicies := []*conf_v1.Policy{validPolicy} expectedErrors := []error{ - errors.New("Policy default/invalid-policy is invalid: spec: Invalid value: \"\": must specify exactly one of: `accessControl`, `rateLimit`, `ingressMTLS`, `egressMTLS`, `jwt`, `oidc`, `waf`"), + errors.New("Policy default/invalid-policy is invalid: spec: Invalid value: \"\": must specify exactly one of: `accessControl`, `rateLimit`, `ingressMTLS`, `egressMTLS`, `basicAuth`, `jwt`, `oidc`, `waf`"), errors.New("Policy nginx-ingress/valid-policy doesn't exist"), errors.New("Failed to get policy nginx-ingress/some-policy: GetByKey error"), errors.New("referenced policy default/valid-policy-ingress-class has incorrect ingress class: test-class (controller ingress class: )"), @@ -958,6 +958,30 @@ func TestFindPoliciesForSecret(t *testing.T) { }, } + basicPol1 := &conf_v1.Policy{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "basic-auth-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + BasicAuth: &conf_v1.BasicAuth{ + Secret: "basic-auth-secret", + }, + }, + } + + basicPol2 := &conf_v1.Policy{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "basic-auth-policy", + Namespace: "ns-1", + }, + Spec: conf_v1.PolicySpec{ + BasicAuth: &conf_v1.BasicAuth{ + Secret: "basic-auth-secret", + }, + }, + } + ingTLSPol := &conf_v1.Policy{ ObjectMeta: meta_v1.ObjectMeta{ Name: "ingress-mtls-policy", @@ -1031,6 +1055,27 @@ func TestFindPoliciesForSecret(t *testing.T) { expected: []*conf_v1.Policy{jwtPol1}, msg: "Find policy in default ns, ignore other", }, + { + policies: []*conf_v1.Policy{basicPol1}, + secretNamespace: "default", + secretName: "basic-auth-secret", + expected: []*conf_v1.Policy{basicPol1}, + msg: "Find policy in default ns", + }, + { + policies: []*conf_v1.Policy{basicPol2}, + secretNamespace: "default", + secretName: "basic-auth-secret", + expected: nil, + msg: "Ignore policies in other namespaces", + }, + { + policies: []*conf_v1.Policy{basicPol1, basicPol2}, + secretNamespace: "default", + secretName: "basic-auth-secret", + expected: []*conf_v1.Policy{basicPol1}, + msg: "Find policy in default ns, ignore other", + }, { policies: []*conf_v1.Policy{ingTLSPol}, secretNamespace: "default", @@ -1228,6 +1273,130 @@ func TestAddJWTSecrets(t *testing.T) { } } +func TestAddBasicSecrets(t *testing.T) { + invalidErr := errors.New("invalid") + validBasicSecret := &v1.Secret{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "valid-basic-auth-secret", + Namespace: "default", + }, + Type: secrets.SecretTypeJWK, + } + invalidBasicSecret := &v1.Secret{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "invalid-basic-auth-secret", + Namespace: "default", + }, + Type: secrets.SecretTypeJWK, + } + + tests := []struct { + policies []*conf_v1.Policy + expectedSecretRefs map[string]*secrets.SecretReference + wantErr bool + msg string + }{ + { + policies: []*conf_v1.Policy{ + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "basic-auth-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + BasicAuth: &conf_v1.BasicAuth{ + Secret: "valid-basic-auth-secret", + Realm: "My API", + }, + }, + }, + }, + expectedSecretRefs: map[string]*secrets.SecretReference{ + "default/valid-basic-auth-secret": { + Secret: validBasicSecret, + Path: "/etc/nginx/secrets/default-valid-basic-auth-secret", + }, + }, + wantErr: false, + msg: "test getting valid secret", + }, + { + policies: []*conf_v1.Policy{}, + expectedSecretRefs: map[string]*secrets.SecretReference{}, + wantErr: false, + msg: "test getting valid secret with no policy", + }, + { + policies: []*conf_v1.Policy{ + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "basic-auth-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + AccessControl: &conf_v1.AccessControl{ + Allow: []string{"127.0.0.1"}, + }, + }, + }, + }, + expectedSecretRefs: map[string]*secrets.SecretReference{}, + wantErr: false, + msg: "test getting invalid secret with wrong policy", + }, + { + policies: []*conf_v1.Policy{ + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "basic-auth-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + BasicAuth: &conf_v1.BasicAuth{ + Secret: "invalid-basic-auth-secret", + Realm: "My API", + }, + }, + }, + }, + expectedSecretRefs: map[string]*secrets.SecretReference{ + "default/invalid-basic-auth-secret": { + Secret: invalidBasicSecret, + Error: invalidErr, + }, + }, + wantErr: true, + msg: "test getting invalid secret", + }, + } + + lbc := LoadBalancerController{ + secretStore: secrets.NewFakeSecretsStore(map[string]*secrets.SecretReference{ + "default/valid-basic-auth-secret": { + Secret: validBasicSecret, + Path: "/etc/nginx/secrets/default-valid-basic-auth-secret", + }, + "default/invalid-basic-auth-secret": { + Secret: invalidBasicSecret, + Error: invalidErr, + }, + }), + } + + for _, test := range tests { + result := make(map[string]*secrets.SecretReference) + + err := lbc.addBasicSecretRefs(result, test.policies) + if (err != nil) != test.wantErr { + t.Errorf("addBasicSecretRefs() returned %v, for the case of %v", err, test.msg) + } + + if diff := cmp.Diff(test.expectedSecretRefs, result, cmp.Comparer(errorComparer)); diff != "" { + t.Errorf("addBasicSecretRefs() '%v' mismatch (-want +got):\n%s", test.msg, diff) + } + } +} + func TestAddIngressMTLSSecret(t *testing.T) { invalidErr := errors.New("invalid") validSecret := &v1.Secret{ diff --git a/internal/k8s/secrets/validation.go b/internal/k8s/secrets/validation.go index d0771755dd..7599d92169 100644 --- a/internal/k8s/secrets/validation.go +++ b/internal/k8s/secrets/validation.go @@ -19,6 +19,9 @@ const CAKey = "ca.crt" // ClientSecretKey is the key of the data field of a Secret where the OIDC client secret must be stored. const ClientSecretKey = "client-secret" +// HtpasswdFileKey is the key of the data field of a Secret where the HTTP basic authorization list must be stored +const HtpasswdFileKey = "htpasswd" + // SecretTypeCA contains a certificate authority for TLS certificate verification. #nosec G101 const SecretTypeCA api_v1.SecretType = "nginx.org/ca" @@ -28,6 +31,9 @@ const SecretTypeJWK api_v1.SecretType = "nginx.org/jwk" // SecretTypeOIDC contains an OIDC client secret for use in oauth flows. #nosec G101 const SecretTypeOIDC api_v1.SecretType = "nginx.org/oidc" +// SecretTypeHtpasswd contains an htpasswd file for use in HTTP Basic authorization.. #nosec G101 +const SecretTypeHtpasswd api_v1.SecretType = "nginx.org/htpasswd" // #nosec G101 + // ValidateTLSSecret validates the secret. If it is valid, the function returns nil. func ValidateTLSSecret(secret *api_v1.Secret) error { if secret.Type != api_v1.SecretTypeTLS { @@ -103,12 +109,29 @@ func ValidateOIDCSecret(secret *api_v1.Secret) error { return nil } +// ValidateHtpasswdSecret validates the secret. If it is valid, the function returns nil. +func ValidateHtpasswdSecret(secret *api_v1.Secret) error { + if secret.Type != SecretTypeHtpasswd { + return fmt.Errorf("Htpasswd secret must be of the type %v", SecretTypeHtpasswd) + } + + if _, exists := secret.Data[HtpasswdFileKey]; !exists { + return fmt.Errorf("Htpasswd secret must have the data field %v", HtpasswdFileKey) + } + + // we don't validate the contents of secret.Data[HtpasswdFileKey], because invalid contents will not make NGINX + // fail to reload: NGINX will return 403 responses for the affected URLs. + + return nil +} + // IsSupportedSecretType checks if the secret type is supported. func IsSupportedSecretType(secretType api_v1.SecretType) bool { return secretType == api_v1.SecretTypeTLS || secretType == SecretTypeCA || secretType == SecretTypeJWK || - secretType == SecretTypeOIDC + secretType == SecretTypeOIDC || + secretType == SecretTypeHtpasswd } // ValidateSecret validates the secret. If it is valid, the function returns nil. @@ -122,6 +145,8 @@ func ValidateSecret(secret *api_v1.Secret) error { return ValidateCASecret(secret) case SecretTypeOIDC: return ValidateOIDCSecret(secret) + case SecretTypeHtpasswd: + return ValidateHtpasswdSecret(secret) } return fmt.Errorf("Secret is of the unsupported type %v", secret.Type) diff --git a/internal/k8s/secrets/validation_test.go b/internal/k8s/secrets/validation_test.go index be23302e18..4539ec4cc6 100644 --- a/internal/k8s/secrets/validation_test.go +++ b/internal/k8s/secrets/validation_test.go @@ -65,6 +65,64 @@ func TestValidateJWKSecretFails(t *testing.T) { } } +func TestValidateHtpasswdSecret(t *testing.T) { + t.Parallel() + secret := &v1.Secret{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "htpasswd-secret", + Namespace: "default", + }, + Type: SecretTypeHtpasswd, + Data: map[string][]byte{ + "htpasswd": nil, + }, + } + + err := ValidateHtpasswdSecret(secret) + if err != nil { + t.Errorf("ValidateHtpasswdSecret() returned error %v", err) + } +} + +func TestValidateHtpasswdSecretFails(t *testing.T) { + t.Parallel() + tests := []struct { + secret *v1.Secret + msg string + }{ + { + secret: &v1.Secret{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "htpasswd-secret", + Namespace: "default", + }, + Type: "some-type", + Data: map[string][]byte{ + "htpasswd": nil, + }, + }, + msg: "Incorrect type for Htpasswd secret", + }, + { + secret: &v1.Secret{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "htpasswd-secret", + Namespace: "default", + }, + Type: SecretTypeHtpasswd, + }, + msg: "Missing htpasswd for Htpasswd secret", + }, + } + + for _, test := range tests { + err := ValidateHtpasswdSecret(test.secret) + if err == nil { + t.Errorf("ValidateHtpasswdSecret() returned no error for the case of %s", test.msg) + } + } +} + func TestValidateCASecret(t *testing.T) { t.Parallel() secret := &v1.Secret{ @@ -366,6 +424,19 @@ func TestValidateSecret(t *testing.T) { }, msg: "Valid JWK secret", }, + { + secret: &v1.Secret{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "htpasswd-secret", + Namespace: "default", + }, + Type: SecretTypeHtpasswd, + Data: map[string][]byte{ + "htpasswd": nil, + }, + }, + msg: "Valid Htpasswd secret", + }, { secret: &v1.Secret{ ObjectMeta: meta_v1.ObjectMeta{ @@ -428,6 +499,16 @@ func TestValidateSecretFails(t *testing.T) { }, msg: "Missing jwk for JWK secret", }, + { + secret: &v1.Secret{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "htpasswd-secret", + Namespace: "default", + }, + Type: SecretTypeHtpasswd, + }, + msg: "Missing htpasswd for Htpasswd secret", + }, } for _, test := range tests { @@ -460,6 +541,10 @@ func TestHasCorrectSecretType(t *testing.T) { secretType: SecretTypeOIDC, expected: true, }, + { + secretType: SecretTypeHtpasswd, + expected: true, + }, { secretType: "some-type", expected: false, diff --git a/internal/nginx/manager.go b/internal/nginx/manager.go index ba333faa2a..93949f20fd 100644 --- a/internal/nginx/manager.go +++ b/internal/nginx/manager.go @@ -24,7 +24,10 @@ const ( // TLSSecretFileMode defines the default filemode for files with TLS Secrets. TLSSecretFileMode = 0o600 // JWKSecretFileMode defines the default filemode for files with JWK Secrets. - JWKSecretFileMode = 0o644 + JWKSecretFileMode = 0o644 + // HtpasswdSecretFileMode defines the default filemode for HTTP basic auth user files. + HtpasswdSecretFileMode = 0o644 + configFileMode = 0o644 jsonFileForOpenTracingTracer = "/var/lib/nginx/tracer-config.json" nginxBinaryPath = "/usr/sbin/nginx" diff --git a/pkg/apis/configuration/v1/types.go b/pkg/apis/configuration/v1/types.go index ad76a57f7b..58e05afd01 100644 --- a/pkg/apis/configuration/v1/types.go +++ b/pkg/apis/configuration/v1/types.go @@ -370,6 +370,7 @@ type PolicySpec struct { AccessControl *AccessControl `json:"accessControl"` RateLimit *RateLimit `json:"rateLimit"` JWTAuth *JWTAuth `json:"jwt"` + BasicAuth *BasicAuth `json:"basicAuth"` IngressMTLS *IngressMTLS `json:"ingressMTLS"` EgressMTLS *EgressMTLS `json:"egressMTLS"` OIDC *OIDC `json:"oidc"` @@ -412,6 +413,13 @@ type JWTAuth struct { Token string `json:"token"` } +// BasicAuth holds HTTP Basic authentication configuration +// policy status: preview +type BasicAuth struct { + Realm string `json:"realm"` + Secret string `json:"secret"` +} + // IngressMTLS defines an Ingress MTLS policy. type IngressMTLS struct { ClientCertSecret string `json:"clientCertSecret"` diff --git a/pkg/apis/configuration/v1/zz_generated.deepcopy.go b/pkg/apis/configuration/v1/zz_generated.deepcopy.go index 41fe869a3c..f5b66e0b03 100644 --- a/pkg/apis/configuration/v1/zz_generated.deepcopy.go +++ b/pkg/apis/configuration/v1/zz_generated.deepcopy.go @@ -141,6 +141,22 @@ func (in *AddHeader) DeepCopy() *AddHeader { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BasicAuth) DeepCopyInto(out *BasicAuth) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BasicAuth. +func (in *BasicAuth) DeepCopy() *BasicAuth { + if in == nil { + return nil + } + out := new(BasicAuth) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CertManager) DeepCopyInto(out *CertManager) { *out = *in @@ -518,6 +534,11 @@ func (in *PolicySpec) DeepCopyInto(out *PolicySpec) { *out = new(JWTAuth) **out = **in } + if in.BasicAuth != nil { + in, out := &in.BasicAuth, &out.BasicAuth + *out = new(BasicAuth) + **out = **in + } if in.IngressMTLS != nil { in, out := &in.IngressMTLS, &out.IngressMTLS *out = new(IngressMTLS) diff --git a/pkg/apis/configuration/validation/policy.go b/pkg/apis/configuration/validation/policy.go index 600dc2189e..846fb03f6a 100644 --- a/pkg/apis/configuration/validation/policy.go +++ b/pkg/apis/configuration/validation/policy.go @@ -43,6 +43,11 @@ func validatePolicySpec(spec *v1.PolicySpec, fieldPath *field.Path, isPlus, enab fieldCount++ } + if spec.BasicAuth != nil { + allErrs = append(allErrs, validateBasic(spec.BasicAuth, fieldPath.Child("basicAuth"))...) + fieldCount++ + } + if spec.IngressMTLS != nil { allErrs = append(allErrs, validateIngressMTLS(spec.IngressMTLS, fieldPath.Child("ingressMTLS"))...) fieldCount++ @@ -80,7 +85,7 @@ func validatePolicySpec(spec *v1.PolicySpec, fieldPath *field.Path, isPlus, enab } if fieldCount != 1 { - msg := "must specify exactly one of: `accessControl`, `rateLimit`, `ingressMTLS`, `egressMTLS`" + msg := "must specify exactly one of: `accessControl`, `rateLimit`, `ingressMTLS`, `egressMTLS`, `basicAuth`" if isPlus { msg = fmt.Sprint(msg, ", `jwt`, `oidc`, `waf`") } @@ -148,7 +153,11 @@ func validateRateLimit(rateLimit *v1.RateLimit, fieldPath *field.Path, isPlus bo func validateJWT(jwt *v1.JWTAuth, fieldPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} - allErrs = append(allErrs, validateJWTRealm(jwt.Realm, fieldPath.Child("realm"))...) + if jwt.Realm == "" { + allErrs = append(allErrs, field.Required(fieldPath, "")) + } else { + allErrs = append(allErrs, validateRealm(jwt.Realm, fieldPath.Child("realm"))...) + } if jwt.Secret == "" { return append(allErrs, field.Required(fieldPath.Child("secret"), "")) @@ -160,6 +169,21 @@ func validateJWT(jwt *v1.JWTAuth, fieldPath *field.Path) field.ErrorList { return allErrs } +func validateBasic(basic *v1.BasicAuth, fieldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + if basic.Realm != "" { + allErrs = append(allErrs, validateRealm(basic.Realm, fieldPath.Child("realm"))...) + } + + if basic.Secret == "" { + return append(allErrs, field.Required(fieldPath.Child("secret"), "")) + } + allErrs = append(allErrs, validateSecretName(basic.Secret, fieldPath.Child("secret"))...) + + return allErrs +} + func validateIngressMTLS(ingressMTLS *v1.IngressMTLS, fieldPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} @@ -501,21 +525,17 @@ func validateRateLimitLogLevel(logLevel string, fieldPath *field.Path) field.Err } const ( - jwtRealmFmt = `([^"$\\]|\\[^$])*` - jwtRealmFmtErrMsg string = `a valid realm must have all '"' escaped and must not contain any '$' or end with an unescaped '\'` + realmFmt = `([^"$\\]|\\[^$])*` + realmFmtErrMsg string = `a valid realm must have all '"' escaped and must not contain any '$' or end with an unescaped '\'` ) -var jwtRealmFmtRegexp = regexp.MustCompile("^" + jwtRealmFmt + "$") +var realmFmtRegexp = regexp.MustCompile("^" + realmFmt + "$") -func validateJWTRealm(realm string, fieldPath *field.Path) field.ErrorList { +func validateRealm(realm string, fieldPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} - if realm == "" { - return append(allErrs, field.Required(fieldPath, "")) - } - - if !jwtRealmFmtRegexp.MatchString(realm) { - msg := validation.RegexError(jwtRealmFmtErrMsg, jwtRealmFmt, "MyAPI", "My Product API") + if !realmFmtRegexp.MatchString(realm) { + msg := validation.RegexError(realmFmtErrMsg, realmFmt, "MyAPI", "My Product API") allErrs = append(allErrs, field.Invalid(fieldPath, realm, msg)) } From 3d54dc2d803c6265034b152c5e6ba14eb0dd20fa Mon Sep 17 00:00:00 2001 From: svvac <_@svvac.net> Date: Mon, 13 Dec 2021 20:59:22 +0100 Subject: [PATCH 2/6] add support for HTTP Basic authentication ingress annotations --- internal/configs/annotations.go | 10 +++ internal/configs/config_params.go | 3 + internal/configs/configurator.go | 15 +++- internal/configs/ingress.go | 34 ++++++++ internal/configs/ingress_test.go | 79 +++++++++++++++++++ .../configs/version1/nginx-plus.ingress.tmpl | 15 ++++ internal/configs/version1/nginx.ingress.tmpl | 15 ++++ internal/k8s/controller.go | 12 +++ internal/k8s/reference_checkers.go | 12 +++ internal/k8s/validation.go | 42 ++++++++++ 10 files changed, 234 insertions(+), 3 deletions(-) diff --git a/internal/configs/annotations.go b/internal/configs/annotations.go index f2e2fdc929..f620183083 100644 --- a/internal/configs/annotations.go +++ b/internal/configs/annotations.go @@ -7,6 +7,9 @@ import ( // JWTKeyAnnotation is the annotation where the Secret with a JWK is specified. const JWTKeyAnnotation = "nginx.com/jwt-key" +// BasicAuthSecretAnnotation is the annotation where the Secret with the HTTP basic user list +const BasicAuthSecretAnnotation = "nginx.org/basic-auth-secret" // #nosec G101 + // AppProtectPolicyAnnotation is where the NGINX App Protect policy is specified const AppProtectPolicyAnnotation = "appprotect.f5.com/app-protect-policy" @@ -299,6 +302,13 @@ func parseAnnotations(ingEx *IngressEx, baseCfgParams *ConfigParams, isPlus bool } } + if basicSecret, exists := ingEx.Ingress.Annotations[BasicAuthSecretAnnotation]; exists { + cfgParams.BasicAuthSecret = basicSecret + } + if basicRealm, exists := ingEx.Ingress.Annotations["nginx.org/basic-auth-realm"]; exists { + cfgParams.BasicAuthRealm = basicRealm + } + if values, exists := ingEx.Ingress.Annotations["nginx.org/listen-ports"]; exists { ports, err := ParsePortList(values) if err != nil { diff --git a/internal/configs/config_params.go b/internal/configs/config_params.go index eb56d73ec4..9683b9c321 100644 --- a/internal/configs/config_params.go +++ b/internal/configs/config_params.go @@ -99,6 +99,9 @@ type ConfigParams struct { JWTRealm string JWTToken string + BasicAuthSecret string + BasicAuthRealm string + Ports []int SSLPorts []int diff --git a/internal/configs/configurator.go b/internal/configs/configurator.go index 14d7bec37c..d33c6627d3 100644 --- a/internal/configs/configurator.go +++ b/internal/configs/configurator.go @@ -271,12 +271,15 @@ func (cnf *Configurator) addOrUpdateIngress(ingEx *IngressEx) (Warnings, error) cnf.updateDosResource(ingEx.DosEx) dosResource := getAppProtectDosResource(ingEx.DosEx) + // LocalSecretStore will not set Path if the secret is not on the filesystem. + // However, NGINX configuration for an Ingress resource, to handle the case of a missing secret, + // relies on the path to be always configured. if jwtKey, exists := ingEx.Ingress.Annotations[JWTKeyAnnotation]; exists { - // LocalSecretStore will not set Path if the secret is not on the filesystem. - // However, NGINX configuration for an Ingress resource, to handle the case of a missing secret, - // relies on the path to be always configured. ingEx.SecretRefs[jwtKey].Path = cnf.nginxManager.GetFilenameForSecret(ingEx.Ingress.Namespace + "-" + jwtKey) } + if basicAuth, exists := ingEx.Ingress.Annotations[BasicAuthSecretAnnotation]; exists { + ingEx.SecretRefs[basicAuth].Path = cnf.nginxManager.GetFilenameForSecret(ingEx.Ingress.Namespace + "-" + basicAuth) + } isMinion := false nginxCfg, warnings := generateNginxCfg(ingEx, apResources, dosResource, isMinion, cnf.cfgParams, cnf.isPlus, cnf.IsResolverConfigured(), @@ -320,10 +323,16 @@ func (cnf *Configurator) addOrUpdateMergeableIngress(mergeableIngs *MergeableIng if jwtKey, exists := mergeableIngs.Master.Ingress.Annotations[JWTKeyAnnotation]; exists { mergeableIngs.Master.SecretRefs[jwtKey].Path = cnf.nginxManager.GetFilenameForSecret(mergeableIngs.Master.Ingress.Namespace + "-" + jwtKey) } + if basicAuth, exists := mergeableIngs.Master.Ingress.Annotations[BasicAuthSecretAnnotation]; exists { + mergeableIngs.Master.SecretRefs[basicAuth].Path = cnf.nginxManager.GetFilenameForSecret(mergeableIngs.Master.Ingress.Namespace + "-" + basicAuth) + } for _, minion := range mergeableIngs.Minions { if jwtKey, exists := minion.Ingress.Annotations[JWTKeyAnnotation]; exists { minion.SecretRefs[jwtKey].Path = cnf.nginxManager.GetFilenameForSecret(minion.Ingress.Namespace + "-" + jwtKey) } + if basicAuth, exists := minion.Ingress.Annotations[BasicAuthSecretAnnotation]; exists { + minion.SecretRefs[basicAuth].Path = cnf.nginxManager.GetFilenameForSecret(minion.Ingress.Namespace + "-" + basicAuth) + } } nginxCfg, warnings := generateNginxCfgForMergeableIngresses(mergeableIngs, apResources, dosResource, cnf.cfgParams, cnf.isPlus, diff --git a/internal/configs/ingress.go b/internal/configs/ingress.go index f0f94e08a6..303f2e5040 100644 --- a/internal/configs/ingress.go +++ b/internal/configs/ingress.go @@ -187,6 +187,12 @@ func generateNginxCfg(ingEx *IngressEx, apResources *AppProtectResources, dosRes allWarnings.Add(warnings) } + if !isMinion && cfgParams.BasicAuthSecret != "" { + basicAuth, warnings := generateBasicAuthConfig(ingEx.Ingress, ingEx.SecretRefs, &cfgParams) + server.BasicAuth = basicAuth + allWarnings.Add(warnings) + } + var locations []version1.Location healthChecks := make(map[string]version1.HealthCheck) @@ -237,6 +243,12 @@ func generateNginxCfg(ingEx *IngressEx, apResources *AppProtectResources, dosRes allWarnings.Add(warnings) } + if isMinion && cfgParams.BasicAuthSecret != "" { + basicAuth, warnings := generateBasicAuthConfig(ingEx.Ingress, ingEx.SecretRefs, &cfgParams) + loc.BasicAuth = basicAuth + allWarnings.Add(warnings) + } + locations = append(locations, loc) if loc.Path == "/" { @@ -327,6 +339,28 @@ func generateJWTConfig(owner runtime.Object, secretRefs map[string]*secrets.Secr return jwtAuth, redirectLocation, warnings } +func generateBasicAuthConfig(owner runtime.Object, secretRefs map[string]*secrets.SecretReference, cfgParams *ConfigParams) (*version1.BasicAuth, Warnings) { + warnings := newWarnings() + + secretRef := secretRefs[cfgParams.BasicAuthSecret] + var secretType api_v1.SecretType + if secretRef.Secret != nil { + secretType = secretRef.Secret.Type + } + if secretType != "" && secretType != secrets.SecretTypeHtpasswd { + warnings.AddWarningf(owner, "Basic auth secret %s is of a wrong type '%s', must be '%s'", cfgParams.BasicAuthSecret, secretType, secrets.SecretTypeHtpasswd) + } else if secretRef.Error != nil { + warnings.AddWarningf(owner, "Basic auth secret %s is invalid: %v", cfgParams.BasicAuthSecret, secretRef.Error) + } + + basicAuth := &version1.BasicAuth{ + Secret: secretRef.Path, + Realm: cfgParams.BasicAuthRealm, + } + + return basicAuth, warnings +} + func addSSLConfig(server *version1.Server, owner runtime.Object, host string, ingressTLS []networking.IngressTLS, secretRefs map[string]*secrets.SecretReference, isWildcardEnabled bool, ) Warnings { diff --git a/internal/configs/ingress_test.go b/internal/configs/ingress_test.go index fc99b3aebc..1e3aab9eb0 100644 --- a/internal/configs/ingress_test.go +++ b/internal/configs/ingress_test.go @@ -77,6 +77,37 @@ func TestGenerateNginxCfgForJWT(t *testing.T) { } } +func TestGenerateNginxCfgForBasicAuth(t *testing.T) { + t.Parallel() + cafeIngressEx := createCafeIngressEx() + cafeIngressEx.Ingress.Annotations["nginx.org/basic-auth-secret"] = "cafe-htpasswd" + cafeIngressEx.Ingress.Annotations["nginx.org/basic-auth-realm"] = "Cafe App" + cafeIngressEx.SecretRefs["cafe-htpasswd"] = &secrets.SecretReference{ + Secret: &v1.Secret{ + Type: secrets.SecretTypeHtpasswd, + }, + Path: "/etc/nginx/secrets/default-cafe-htpasswd", + } + + isPlus := false + configParams := NewDefaultConfigParams(isPlus) + + expected := createExpectedConfigForCafeIngressEx(isPlus) + expected.Servers[0].BasicAuth = &version1.BasicAuth{ + Secret: "/etc/nginx/secrets/default-cafe-htpasswd", + Realm: "Cafe App", + } + + result, warnings := generateNginxCfg(&cafeIngressEx, nil, nil, false, configParams, true, false, &StaticConfigParams{}, false) + + if !reflect.DeepEqual(result.Servers[0].BasicAuth, expected.Servers[0].BasicAuth) { + t.Errorf("generateNginxCfg returned \n%v, but expected \n%v", result.Servers[0].BasicAuth, expected.Servers[0].BasicAuth) + } + if len(warnings) != 0 { + t.Errorf("generateNginxCfg returned warnings: %v", warnings) + } +} + func TestGenerateNginxCfgWithMissingTLSSecret(t *testing.T) { t.Parallel() cafeIngressEx := createCafeIngressEx() @@ -470,6 +501,54 @@ func TestGenerateNginxCfgForMergeableIngressesForJWT(t *testing.T) { } } +func TestGenerateNginxCfgForMergeableIngressesForBasicAuth(t *testing.T) { + t.Parallel() + mergeableIngresses := createMergeableCafeIngress() + mergeableIngresses.Master.Ingress.Annotations["nginx.org/basic-auth-secret"] = "cafe-htpasswd" + mergeableIngresses.Master.Ingress.Annotations["nginx.org/basic-auth-realm"] = "Cafe" + mergeableIngresses.Master.SecretRefs["cafe-htpasswd"] = &secrets.SecretReference{ + Secret: &v1.Secret{ + Type: secrets.SecretTypeHtpasswd, + }, + Path: "/etc/nginx/secrets/default-cafe-htpasswd", + } + + mergeableIngresses.Minions[0].Ingress.Annotations["nginx.org/basic-auth-secret"] = "coffee-htpasswd" + mergeableIngresses.Minions[0].Ingress.Annotations["nginx.org/basic-auth-realm"] = "Coffee" + mergeableIngresses.Minions[0].SecretRefs["coffee-htpasswd"] = &secrets.SecretReference{ + Secret: &v1.Secret{ + Type: secrets.SecretTypeHtpasswd, + }, + Path: "/etc/nginx/secrets/default-coffee-htpasswd", + } + + isPlus := false + + expected := createExpectedConfigForMergeableCafeIngress(isPlus) + expected.Servers[0].BasicAuth = &version1.BasicAuth{ + Secret: "/etc/nginx/secrets/default-cafe-htpasswd", + Realm: "Cafe", + } + expected.Servers[0].Locations[0].BasicAuth = &version1.BasicAuth{ + Secret: "/etc/nginx/secrets/default-coffee-htpasswd", + Realm: "Coffee", + } + + configParams := NewDefaultConfigParams(isPlus) + + result, warnings := generateNginxCfgForMergeableIngresses(mergeableIngresses, nil, nil, configParams, isPlus, false, &StaticConfigParams{}, false) + + if !reflect.DeepEqual(result.Servers[0].BasicAuth, expected.Servers[0].BasicAuth) { + t.Errorf("generateNginxCfgForMergeableIngresses returned \n%v, but expected \n%v", result.Servers[0].BasicAuth, expected.Servers[0].BasicAuth) + } + if !reflect.DeepEqual(result.Servers[0].Locations[0].BasicAuth, expected.Servers[0].Locations[0].BasicAuth) { + t.Errorf("generateNginxCfgForMergeableIngresses returned \n%v, but expected \n%v", result.Servers[0].Locations[0].BasicAuth, expected.Servers[0].Locations[0].BasicAuth) + } + if len(warnings) != 0 { + t.Errorf("generateNginxCfgForMergeableIngresses returned warnings: %v", warnings) + } +} + func createMergeableCafeIngress() *MergeableIngresses { master := networking.Ingress{ ObjectMeta: meta_v1.ObjectMeta{ diff --git a/internal/configs/version1/nginx-plus.ingress.tmpl b/internal/configs/version1/nginx-plus.ingress.tmpl index 7a8329e855..94c692a67c 100644 --- a/internal/configs/version1/nginx-plus.ingress.tmpl +++ b/internal/configs/version1/nginx-plus.ingress.tmpl @@ -136,6 +136,11 @@ server { } {{- end}} + {{- with $server.BasicAuth }} + auth_basic {{ printf "%q" .Realm }}; + auth_basic_user_file {{ .Secret }}; + {{- end }} + {{with $jwt := $server.JWTAuth}} auth_jwt_key_file {{$jwt.Key}}; auth_jwt "{{.Realm}}"{{if $jwt.Token}} token={{$jwt.Token}}{{end}}; @@ -206,6 +211,11 @@ server { auth_jwt "{{.Realm}}"{{if $jwt.Token}} token={{$jwt.Token}}{{end}}; {{end}} + {{- with $location.BasicAuth }} + auth_basic {{ printf "%q" .Realm }}; + auth_basic_user_file {{ .Secret }}; + {{- end }} + grpc_connect_timeout {{$location.ProxyConnectTimeout}}; grpc_read_timeout {{$location.ProxyReadTimeout}}; grpc_send_timeout {{$location.ProxySendTimeout}}; @@ -255,6 +265,11 @@ server { {{end}} {{end}} + {{- with $location.BasicAuth }} + auth_basic {{ printf "%q" .Realm }}; + auth_basic_user_file {{ .Secret }}; + {{- end }} + proxy_connect_timeout {{$location.ProxyConnectTimeout}}; proxy_read_timeout {{$location.ProxyReadTimeout}}; proxy_send_timeout {{$location.ProxySendTimeout}}; diff --git a/internal/configs/version1/nginx.ingress.tmpl b/internal/configs/version1/nginx.ingress.tmpl index 727d5cd2b9..93f25cc53b 100644 --- a/internal/configs/version1/nginx.ingress.tmpl +++ b/internal/configs/version1/nginx.ingress.tmpl @@ -84,6 +84,11 @@ server { } {{- end}} + {{- with $server.BasicAuth }} + auth_basic {{ printf "%q" .Realm }}; + auth_basic_user_file {{ .Secret }}; + {{- end }} + {{- if $server.ServerSnippets}} {{range $value := $server.ServerSnippets}} {{$value}}{{end}} @@ -119,6 +124,11 @@ server { {{$value}}{{end}} {{- end}} + {{- with $location.BasicAuth }} + auth_basic {{ printf "%q" .Realm }}; + auth_basic_user_file {{ .Secret }}; + {{- end }} + grpc_connect_timeout {{$location.ProxyConnectTimeout}}; grpc_read_timeout {{$location.ProxyReadTimeout}}; grpc_send_timeout {{$location.ProxySendTimeout}}; @@ -151,6 +161,11 @@ server { {{$value}}{{end}} {{- end}} + {{- with $location.BasicAuth }} + auth_basic {{ printf "%q" .Realm }}; + auth_basic_user_file {{ .Secret }}; + {{- end }} + proxy_connect_timeout {{$location.ProxyConnectTimeout}}; proxy_read_timeout {{$location.ProxyReadTimeout}}; proxy_send_timeout {{$location.ProxySendTimeout}}; diff --git a/internal/k8s/controller.go b/internal/k8s/controller.go index 3b4fd6037b..5e44779f8f 100644 --- a/internal/k8s/controller.go +++ b/internal/k8s/controller.go @@ -2201,6 +2201,18 @@ func (lbc *LoadBalancerController) createIngressEx(ing *networking.Ingress, vali ingEx.SecretRefs[secretName] = secretRef } + if basicAuth, exists := ingEx.Ingress.Annotations[configs.BasicAuthSecretAnnotation]; exists { + secretName := basicAuth + secretKey := ing.Namespace + "/" + secretName + + secretRef := lbc.secretStore.GetSecret(secretKey) + if secretRef.Error != nil { + glog.Warningf("Error trying to get the secret %v for Ingress %v/%v: %v", secretName, ing.Namespace, ing.Name, secretRef.Error) + } + + ingEx.SecretRefs[secretName] = secretRef + } + if lbc.isNginxPlus { if jwtKey, exists := ingEx.Ingress.Annotations[configs.JWTKeyAnnotation]; exists { secretName := jwtKey diff --git a/internal/k8s/reference_checkers.go b/internal/k8s/reference_checkers.go index 7f87ba2a85..f8fe80467a 100644 --- a/internal/k8s/reference_checkers.go +++ b/internal/k8s/reference_checkers.go @@ -44,6 +44,12 @@ func (rc *secretReferenceChecker) IsReferencedByIngress(secretNamespace string, } } + if basicAuth, exists := ing.Annotations[configs.BasicAuthSecretAnnotation]; exists { + if basicAuth == secretName { + return true + } + } + return false } @@ -60,6 +66,12 @@ func (rc *secretReferenceChecker) IsReferencedByMinion(secretNamespace string, s } } + if basicAuth, exists := ing.Annotations[configs.BasicAuthSecretAnnotation]; exists { + if basicAuth == secretName { + return true + } + } + return false } diff --git a/internal/k8s/validation.go b/internal/k8s/validation.go index 1c2e2bec5e..854e59576e 100644 --- a/internal/k8s/validation.go +++ b/internal/k8s/validation.go @@ -42,6 +42,8 @@ const ( proxyBufferSizeAnnotation = "nginx.org/proxy-buffer-size" proxyMaxTempFileSizeAnnotation = "nginx.org/proxy-max-temp-file-size" upstreamZoneSizeAnnotation = "nginx.org/upstream-zone-size" + basicAuthSecretAnnotation = "nginx.org/basic-auth-secret" // #nosec G101 + basicAuthRealmAnnotation = "nginx.org/basic-auth-realm" jwtRealmAnnotation = "nginx.com/jwt-realm" jwtKeyAnnotation = "nginx.com/jwt-key" jwtTokenAnnotation = "nginx.com/jwt-token" // #nosec G101 @@ -202,6 +204,14 @@ var ( validateRequiredAnnotation, validateSizeAnnotation, }, + basicAuthSecretAnnotation: { + validateRequiredAnnotation, + validateSecretNameAnnotation, + }, + basicAuthRealmAnnotation: { + validateRelatedAnnotation(basicAuthSecretAnnotation, validateNoop), + validateRealmAnnotation, + }, jwtRealmAnnotation: { validatePlusOnlyAnnotation, validateRequiredAnnotation, @@ -667,6 +677,25 @@ func validateSnippetsAnnotation(context *annotationValidationContext) field.Erro return allErrs } +func validateSecretNameAnnotation(context *annotationValidationContext) field.ErrorList { + allErrs := field.ErrorList{} + if msgs := validation.IsDNS1123Subdomain(context.value); msgs != nil { + for _, msg := range msgs { + allErrs = append(allErrs, field.Invalid(context.fieldPath, context.value, msg)) + } + return allErrs + } + return allErrs +} + +func validateRealmAnnotation(context *annotationValidationContext) field.ErrorList { + allErrs := field.ErrorList{} + if err := validateIsValidRealm(context.value); err != nil { + return append(allErrs, field.Invalid(context.fieldPath, context.value, err.Error())) + } + return allErrs +} + func validateIsBool(v string) error { _, err := configs.ParseBool(v) return err @@ -683,6 +712,19 @@ func validateIsTrue(v string) error { return nil } +func validateNoop(_ string) error { + return nil +} + +var realmFmtRegexp = regexp.MustCompile(`^([^"$\\]|\\[^$])*$`) + +func validateIsValidRealm(v string) error { + if !realmFmtRegexp.MatchString(v) { + return errors.New(`a valid realm must have all '"' escaped and must not contain any '$' or end with an unescaped '\'`) + } + return nil +} + func validateIngressSpec(spec *networking.IngressSpec, fieldPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} From 013a938f910b13c47b664a9d5e82298ba8860ac2 Mon Sep 17 00:00:00 2001 From: Remi COMBE Date: Thu, 21 Apr 2022 09:34:21 +0200 Subject: [PATCH 3/6] add integration tests for HTTP basic authentication policy --- tests/data/auth-basic-policy/credentials.txt | 1 + tests/data/auth-basic-policy/credentials2.txt | 1 + .../invalid-credentials-no-pwd.txt | 1 + .../invalid-credentials-no-user.txt | 1 + .../invalid-credentials-pwd.txt | 1 + .../invalid-credentials-user.txt | 1 + .../auth-basic-policy/invalid-credentials.txt | 1 + .../auth-basic-policy-invalid-secret.yaml | 8 + .../policies/auth-basic-policy-invalid.yaml | 8 + .../auth-basic-policy-valid-multi.yaml | 8 + .../policies/auth-basic-policy-valid.yaml | 8 + .../virtual-server-override-route.yaml | 23 + .../virtual-server-override-spec-route-1.yaml | 24 + .../virtual-server-override-spec-route-2.yaml | 24 + ...-server-route-invalid-subroute-secret.yaml | 22 + ...virtual-server-route-invalid-subroute.yaml | 22 + ...irtual-server-route-override-subroute.yaml | 23 + ...ual-server-route-valid-subroute-multi.yaml | 22 + .../virtual-server-route-valid-subroute.yaml | 22 + .../virtual-server-vsr-route-override.yaml | 13 + .../virtual-server-vsr-spec-override.yaml | 13 + .../secret/htpasswd-secret-invalid.yaml | 9 + .../secret/htpasswd-secret-valid-empty.yaml | 7 + .../secret/htpasswd-secret-valid.yaml | 12 + .../spec/virtual-server-policy-multi-1.yaml | 23 + .../spec/virtual-server-policy-multi-2.yaml | 23 + ...tual-server-policy-single-invalid-pol.yaml | 22 + ...l-server-policy-single-invalid-secret.yaml | 22 + .../spec/virtual-server-policy-single.yaml | 22 + tests/suite/test_auth_basic_policies.py | 513 ++++++++++++++++ tests/suite/test_auth_basic_policies_vsr.py | 565 ++++++++++++++++++ 31 files changed, 1465 insertions(+) create mode 100644 tests/data/auth-basic-policy/credentials.txt create mode 100644 tests/data/auth-basic-policy/credentials2.txt create mode 100644 tests/data/auth-basic-policy/invalid-credentials-no-pwd.txt create mode 100644 tests/data/auth-basic-policy/invalid-credentials-no-user.txt create mode 100644 tests/data/auth-basic-policy/invalid-credentials-pwd.txt create mode 100644 tests/data/auth-basic-policy/invalid-credentials-user.txt create mode 100644 tests/data/auth-basic-policy/invalid-credentials.txt create mode 100644 tests/data/auth-basic-policy/policies/auth-basic-policy-invalid-secret.yaml create mode 100644 tests/data/auth-basic-policy/policies/auth-basic-policy-invalid.yaml create mode 100644 tests/data/auth-basic-policy/policies/auth-basic-policy-valid-multi.yaml create mode 100644 tests/data/auth-basic-policy/policies/auth-basic-policy-valid.yaml create mode 100644 tests/data/auth-basic-policy/route-subroute/virtual-server-override-route.yaml create mode 100644 tests/data/auth-basic-policy/route-subroute/virtual-server-override-spec-route-1.yaml create mode 100644 tests/data/auth-basic-policy/route-subroute/virtual-server-override-spec-route-2.yaml create mode 100644 tests/data/auth-basic-policy/route-subroute/virtual-server-route-invalid-subroute-secret.yaml create mode 100644 tests/data/auth-basic-policy/route-subroute/virtual-server-route-invalid-subroute.yaml create mode 100644 tests/data/auth-basic-policy/route-subroute/virtual-server-route-override-subroute.yaml create mode 100644 tests/data/auth-basic-policy/route-subroute/virtual-server-route-valid-subroute-multi.yaml create mode 100644 tests/data/auth-basic-policy/route-subroute/virtual-server-route-valid-subroute.yaml create mode 100644 tests/data/auth-basic-policy/route-subroute/virtual-server-vsr-route-override.yaml create mode 100644 tests/data/auth-basic-policy/route-subroute/virtual-server-vsr-spec-override.yaml create mode 100644 tests/data/auth-basic-policy/secret/htpasswd-secret-invalid.yaml create mode 100644 tests/data/auth-basic-policy/secret/htpasswd-secret-valid-empty.yaml create mode 100644 tests/data/auth-basic-policy/secret/htpasswd-secret-valid.yaml create mode 100644 tests/data/auth-basic-policy/spec/virtual-server-policy-multi-1.yaml create mode 100644 tests/data/auth-basic-policy/spec/virtual-server-policy-multi-2.yaml create mode 100644 tests/data/auth-basic-policy/spec/virtual-server-policy-single-invalid-pol.yaml create mode 100644 tests/data/auth-basic-policy/spec/virtual-server-policy-single-invalid-secret.yaml create mode 100644 tests/data/auth-basic-policy/spec/virtual-server-policy-single.yaml create mode 100644 tests/suite/test_auth_basic_policies.py create mode 100644 tests/suite/test_auth_basic_policies_vsr.py diff --git a/tests/data/auth-basic-policy/credentials.txt b/tests/data/auth-basic-policy/credentials.txt new file mode 100644 index 0000000000..ed3b07faeb --- /dev/null +++ b/tests/data/auth-basic-policy/credentials.txt @@ -0,0 +1 @@ +foo:bar \ No newline at end of file diff --git a/tests/data/auth-basic-policy/credentials2.txt b/tests/data/auth-basic-policy/credentials2.txt new file mode 100644 index 0000000000..25735fbfdf --- /dev/null +++ b/tests/data/auth-basic-policy/credentials2.txt @@ -0,0 +1 @@ +qux:quux \ No newline at end of file diff --git a/tests/data/auth-basic-policy/invalid-credentials-no-pwd.txt b/tests/data/auth-basic-policy/invalid-credentials-no-pwd.txt new file mode 100644 index 0000000000..09ab8a9ca1 --- /dev/null +++ b/tests/data/auth-basic-policy/invalid-credentials-no-pwd.txt @@ -0,0 +1 @@ +foo: \ No newline at end of file diff --git a/tests/data/auth-basic-policy/invalid-credentials-no-user.txt b/tests/data/auth-basic-policy/invalid-credentials-no-user.txt new file mode 100644 index 0000000000..b6abbfff05 --- /dev/null +++ b/tests/data/auth-basic-policy/invalid-credentials-no-user.txt @@ -0,0 +1 @@ +:bar \ No newline at end of file diff --git a/tests/data/auth-basic-policy/invalid-credentials-pwd.txt b/tests/data/auth-basic-policy/invalid-credentials-pwd.txt new file mode 100644 index 0000000000..de6b54626a --- /dev/null +++ b/tests/data/auth-basic-policy/invalid-credentials-pwd.txt @@ -0,0 +1 @@ +foo:baz \ No newline at end of file diff --git a/tests/data/auth-basic-policy/invalid-credentials-user.txt b/tests/data/auth-basic-policy/invalid-credentials-user.txt new file mode 100644 index 0000000000..14bab6d037 --- /dev/null +++ b/tests/data/auth-basic-policy/invalid-credentials-user.txt @@ -0,0 +1 @@ +foobar:bar \ No newline at end of file diff --git a/tests/data/auth-basic-policy/invalid-credentials.txt b/tests/data/auth-basic-policy/invalid-credentials.txt new file mode 100644 index 0000000000..f6ea049518 --- /dev/null +++ b/tests/data/auth-basic-policy/invalid-credentials.txt @@ -0,0 +1 @@ +foobar \ No newline at end of file diff --git a/tests/data/auth-basic-policy/policies/auth-basic-policy-invalid-secret.yaml b/tests/data/auth-basic-policy/policies/auth-basic-policy-invalid-secret.yaml new file mode 100644 index 0000000000..8e1128325e --- /dev/null +++ b/tests/data/auth-basic-policy/policies/auth-basic-policy-invalid-secret.yaml @@ -0,0 +1,8 @@ +apiVersion: k8s.nginx.org/v1 +kind: Policy +metadata: + name: auth-basic-policy-invalid-secret +spec: + basicAuth: + realm: MyProductAPI + secret: htpasswd-secret-invalid diff --git a/tests/data/auth-basic-policy/policies/auth-basic-policy-invalid.yaml b/tests/data/auth-basic-policy/policies/auth-basic-policy-invalid.yaml new file mode 100644 index 0000000000..1a3db0d544 --- /dev/null +++ b/tests/data/auth-basic-policy/policies/auth-basic-policy-invalid.yaml @@ -0,0 +1,8 @@ +apiVersion: k8s.nginx.org/v1 +kind: Policy +metadata: + name: auth-basic-policy-invalid +spec: + basicAuth: + realm: MyProductAPI + secret: $$htpasswd-secret-valid diff --git a/tests/data/auth-basic-policy/policies/auth-basic-policy-valid-multi.yaml b/tests/data/auth-basic-policy/policies/auth-basic-policy-valid-multi.yaml new file mode 100644 index 0000000000..78680377de --- /dev/null +++ b/tests/data/auth-basic-policy/policies/auth-basic-policy-valid-multi.yaml @@ -0,0 +1,8 @@ +apiVersion: k8s.nginx.org/v1 +kind: Policy +metadata: + name: auth-basic-policy-valid-multi +spec: + basicAuth: + realm: MyProductAPI + secret: htpasswd-secret-valid-empty diff --git a/tests/data/auth-basic-policy/policies/auth-basic-policy-valid.yaml b/tests/data/auth-basic-policy/policies/auth-basic-policy-valid.yaml new file mode 100644 index 0000000000..64918e9284 --- /dev/null +++ b/tests/data/auth-basic-policy/policies/auth-basic-policy-valid.yaml @@ -0,0 +1,8 @@ +apiVersion: k8s.nginx.org/v1 +kind: Policy +metadata: + name: auth-basic-policy-valid +spec: + basicAuth: + realm: MyProductAPI + secret: htpasswd-secret-valid diff --git a/tests/data/auth-basic-policy/route-subroute/virtual-server-override-route.yaml b/tests/data/auth-basic-policy/route-subroute/virtual-server-override-route.yaml new file mode 100644 index 0000000000..ec137749b7 --- /dev/null +++ b/tests/data/auth-basic-policy/route-subroute/virtual-server-override-route.yaml @@ -0,0 +1,23 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: virtual-server +spec: + host: virtual-server.example.com + upstreams: + - name: backend2 + service: backend2-svc + port: 80 + - name: backend1 + service: backend1-svc + port: 80 + routes: + - path: "/backend1" + policies: + - name: auth-basic-policy-valid-multi + - name: auth-basic-policy-valid + action: + pass: backend1 + - path: "/backend2" + action: + pass: backend2 \ No newline at end of file diff --git a/tests/data/auth-basic-policy/route-subroute/virtual-server-override-spec-route-1.yaml b/tests/data/auth-basic-policy/route-subroute/virtual-server-override-spec-route-1.yaml new file mode 100644 index 0000000000..09d9338187 --- /dev/null +++ b/tests/data/auth-basic-policy/route-subroute/virtual-server-override-spec-route-1.yaml @@ -0,0 +1,24 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: virtual-server +spec: + host: virtual-server.example.com + policies: + - name: auth-basic-policy-valid + upstreams: + - name: backend2 + service: backend2-svc + port: 80 + - name: backend1 + service: backend1-svc + port: 80 + routes: + - path: "/backend1" + policies: + - name: auth-basic-policy-valid-multi + action: + pass: backend1 + - path: "/backend2" + action: + pass: backend2 \ No newline at end of file diff --git a/tests/data/auth-basic-policy/route-subroute/virtual-server-override-spec-route-2.yaml b/tests/data/auth-basic-policy/route-subroute/virtual-server-override-spec-route-2.yaml new file mode 100644 index 0000000000..4ff4a584d4 --- /dev/null +++ b/tests/data/auth-basic-policy/route-subroute/virtual-server-override-spec-route-2.yaml @@ -0,0 +1,24 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: virtual-server +spec: + host: virtual-server.example.com + policies: + - name: auth-basic-policy-valid-multi + upstreams: + - name: backend2 + service: backend2-svc + port: 80 + - name: backend1 + service: backend1-svc + port: 80 + routes: + - path: "/backend1" + policies: + - name: auth-basic-policy-valid + action: + pass: backend1 + - path: "/backend2" + action: + pass: backend2 \ No newline at end of file diff --git a/tests/data/auth-basic-policy/route-subroute/virtual-server-route-invalid-subroute-secret.yaml b/tests/data/auth-basic-policy/route-subroute/virtual-server-route-invalid-subroute-secret.yaml new file mode 100644 index 0000000000..91fbbb36a2 --- /dev/null +++ b/tests/data/auth-basic-policy/route-subroute/virtual-server-route-invalid-subroute-secret.yaml @@ -0,0 +1,22 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServerRoute +metadata: + name: backends +spec: + host: virtual-server-route.example.com + upstreams: + - name: backend1 + service: backend1-svc + port: 80 + - name: backend3 + service: backend3-svc + port: 80 + subroutes: + - path: "/backends/backend1" + policies: + - name: auth-basic-policy-invalid-secret + action: + pass: backend1 + - path: "/backends/backend3" + action: + pass: backend3 \ No newline at end of file diff --git a/tests/data/auth-basic-policy/route-subroute/virtual-server-route-invalid-subroute.yaml b/tests/data/auth-basic-policy/route-subroute/virtual-server-route-invalid-subroute.yaml new file mode 100644 index 0000000000..0929d9de51 --- /dev/null +++ b/tests/data/auth-basic-policy/route-subroute/virtual-server-route-invalid-subroute.yaml @@ -0,0 +1,22 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServerRoute +metadata: + name: backends +spec: + host: virtual-server-route.example.com + upstreams: + - name: backend1 + service: backend1-svc + port: 80 + - name: backend3 + service: backend3-svc + port: 80 + subroutes: + - path: "/backends/backend1" + policies: + - name: auth-basic-policy-invalid + action: + pass: backend1 + - path: "/backends/backend3" + action: + pass: backend3 \ No newline at end of file diff --git a/tests/data/auth-basic-policy/route-subroute/virtual-server-route-override-subroute.yaml b/tests/data/auth-basic-policy/route-subroute/virtual-server-route-override-subroute.yaml new file mode 100644 index 0000000000..29f28d6bfc --- /dev/null +++ b/tests/data/auth-basic-policy/route-subroute/virtual-server-route-override-subroute.yaml @@ -0,0 +1,23 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServerRoute +metadata: + name: backends +spec: + host: virtual-server-route.example.com + upstreams: + - name: backend1 + service: backend1-svc + port: 80 + - name: backend3 + service: backend3-svc + port: 80 + subroutes: + - path: "/backends/backend1" + policies: + - name: auth-basic-policy-valid-multi + - name: auth-basic-policy-valid + action: + pass: backend1 + - path: "/backends/backend3" + action: + pass: backend3 \ No newline at end of file diff --git a/tests/data/auth-basic-policy/route-subroute/virtual-server-route-valid-subroute-multi.yaml b/tests/data/auth-basic-policy/route-subroute/virtual-server-route-valid-subroute-multi.yaml new file mode 100644 index 0000000000..ef406e5050 --- /dev/null +++ b/tests/data/auth-basic-policy/route-subroute/virtual-server-route-valid-subroute-multi.yaml @@ -0,0 +1,22 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServerRoute +metadata: + name: backends +spec: + host: virtual-server-route.example.com + upstreams: + - name: backend1 + service: backend1-svc + port: 80 + - name: backend3 + service: backend3-svc + port: 80 + subroutes: + - path: "/backends/backend1" + policies: + - name: auth-basic-policy-valid-multi + action: + pass: backend1 + - path: "/backends/backend3" + action: + pass: backend3 \ No newline at end of file diff --git a/tests/data/auth-basic-policy/route-subroute/virtual-server-route-valid-subroute.yaml b/tests/data/auth-basic-policy/route-subroute/virtual-server-route-valid-subroute.yaml new file mode 100644 index 0000000000..73e06a61a4 --- /dev/null +++ b/tests/data/auth-basic-policy/route-subroute/virtual-server-route-valid-subroute.yaml @@ -0,0 +1,22 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServerRoute +metadata: + name: backends +spec: + host: virtual-server-route.example.com + upstreams: + - name: backend1 + service: backend1-svc + port: 80 + - name: backend3 + service: backend3-svc + port: 80 + subroutes: + - path: "/backends/backend1" + policies: + - name: auth-basic-policy-valid + action: + pass: backend1 + - path: "/backends/backend3" + action: + pass: backend3 \ No newline at end of file diff --git a/tests/data/auth-basic-policy/route-subroute/virtual-server-vsr-route-override.yaml b/tests/data/auth-basic-policy/route-subroute/virtual-server-vsr-route-override.yaml new file mode 100644 index 0000000000..f84987413a --- /dev/null +++ b/tests/data/auth-basic-policy/route-subroute/virtual-server-vsr-route-override.yaml @@ -0,0 +1,13 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: virtual-server-route +spec: + host: virtual-server-route.example.com + routes: + - path: "/backends" + policies: + - name: auth-basic-policy-valid + route: backends + - path: "/backend2" + route: backend2-namespace/backend2 \ No newline at end of file diff --git a/tests/data/auth-basic-policy/route-subroute/virtual-server-vsr-spec-override.yaml b/tests/data/auth-basic-policy/route-subroute/virtual-server-vsr-spec-override.yaml new file mode 100644 index 0000000000..8b15168a12 --- /dev/null +++ b/tests/data/auth-basic-policy/route-subroute/virtual-server-vsr-spec-override.yaml @@ -0,0 +1,13 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: virtual-server-route +spec: + host: virtual-server-route.example.com + policies: + - name: auth-basic-policy-valid + routes: + - path: "/backends" + route: backends + - path: "/backend2" + route: backend2-namespace/backend2 \ No newline at end of file diff --git a/tests/data/auth-basic-policy/secret/htpasswd-secret-invalid.yaml b/tests/data/auth-basic-policy/secret/htpasswd-secret-invalid.yaml new file mode 100644 index 0000000000..bbbd67ad22 --- /dev/null +++ b/tests/data/auth-basic-policy/secret/htpasswd-secret-invalid.yaml @@ -0,0 +1,9 @@ +kind: Secret +metadata: + name: htpasswd-secret-invalid +apiVersion: v1 +type: nginx.org/htpasswd +stringData: + invalid-htpasswd: | + foo:$2y$10$mnb.J7DxTtC8/2EGRkmwsehTlNgQS0VbaryPr19aqIgI6IaukL77u + qux:$apr1$st218vzc$A3H7I83N9vLmczj73Byi3/ diff --git a/tests/data/auth-basic-policy/secret/htpasswd-secret-valid-empty.yaml b/tests/data/auth-basic-policy/secret/htpasswd-secret-valid-empty.yaml new file mode 100644 index 0000000000..a61bc8f8c2 --- /dev/null +++ b/tests/data/auth-basic-policy/secret/htpasswd-secret-valid-empty.yaml @@ -0,0 +1,7 @@ +kind: Secret +metadata: + name: htpasswd-secret-valid-empty +apiVersion: v1 +type: nginx.org/htpasswd +stringData: + htpasswd: "" diff --git a/tests/data/auth-basic-policy/secret/htpasswd-secret-valid.yaml b/tests/data/auth-basic-policy/secret/htpasswd-secret-valid.yaml new file mode 100644 index 0000000000..0b99ef082f --- /dev/null +++ b/tests/data/auth-basic-policy/secret/htpasswd-secret-valid.yaml @@ -0,0 +1,12 @@ +kind: Secret +metadata: + name: htpasswd-secret-valid +apiVersion: v1 +type: nginx.org/htpasswd +stringData: + htpasswd: | + foo:$2y$10$mnb.J7DxTtC8/2EGRkmwsehTlNgQS0VbaryPr19aqIgI6IaukL77u + qux:$apr1$st218vzc$A3H7I83N9vLmczj73Byi3/ + # bar + # quux + diff --git a/tests/data/auth-basic-policy/spec/virtual-server-policy-multi-1.yaml b/tests/data/auth-basic-policy/spec/virtual-server-policy-multi-1.yaml new file mode 100644 index 0000000000..ec99289fce --- /dev/null +++ b/tests/data/auth-basic-policy/spec/virtual-server-policy-multi-1.yaml @@ -0,0 +1,23 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: virtual-server +spec: + host: virtual-server.example.com + policies: + - name: auth-basic-policy-valid-multi + - name: auth-basic-policy-valid + upstreams: + - name: backend2 + service: backend2-svc + port: 80 + - name: backend1 + service: backend1-svc + port: 80 + routes: + - path: "/backend1" + action: + pass: backend1 + - path: "/backend2" + action: + pass: backend2 \ No newline at end of file diff --git a/tests/data/auth-basic-policy/spec/virtual-server-policy-multi-2.yaml b/tests/data/auth-basic-policy/spec/virtual-server-policy-multi-2.yaml new file mode 100644 index 0000000000..d4c07dac15 --- /dev/null +++ b/tests/data/auth-basic-policy/spec/virtual-server-policy-multi-2.yaml @@ -0,0 +1,23 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: virtual-server +spec: + host: virtual-server.example.com + policies: + - name: auth-basic-policy-valid + - name: auth-basic-policy-valid-multi + upstreams: + - name: backend2 + service: backend2-svc + port: 80 + - name: backend1 + service: backend1-svc + port: 80 + routes: + - path: "/backend1" + action: + pass: backend1 + - path: "/backend2" + action: + pass: backend2 \ No newline at end of file diff --git a/tests/data/auth-basic-policy/spec/virtual-server-policy-single-invalid-pol.yaml b/tests/data/auth-basic-policy/spec/virtual-server-policy-single-invalid-pol.yaml new file mode 100644 index 0000000000..6e133632cd --- /dev/null +++ b/tests/data/auth-basic-policy/spec/virtual-server-policy-single-invalid-pol.yaml @@ -0,0 +1,22 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: virtual-server +spec: + host: virtual-server.example.com + policies: + - name: auth-basic-policy-invalid + upstreams: + - name: backend2 + service: backend2-svc + port: 80 + - name: backend1 + service: backend1-svc + port: 80 + routes: + - path: "/backend1" + action: + pass: backend1 + - path: "/backend2" + action: + pass: backend2 \ No newline at end of file diff --git a/tests/data/auth-basic-policy/spec/virtual-server-policy-single-invalid-secret.yaml b/tests/data/auth-basic-policy/spec/virtual-server-policy-single-invalid-secret.yaml new file mode 100644 index 0000000000..1393b111d2 --- /dev/null +++ b/tests/data/auth-basic-policy/spec/virtual-server-policy-single-invalid-secret.yaml @@ -0,0 +1,22 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: virtual-server +spec: + host: virtual-server.example.com + policies: + - name: auth-basic-policy-invalid-secret + upstreams: + - name: backend2 + service: backend2-svc + port: 80 + - name: backend1 + service: backend1-svc + port: 80 + routes: + - path: "/backend1" + action: + pass: backend1 + - path: "/backend2" + action: + pass: backend2 \ No newline at end of file diff --git a/tests/data/auth-basic-policy/spec/virtual-server-policy-single.yaml b/tests/data/auth-basic-policy/spec/virtual-server-policy-single.yaml new file mode 100644 index 0000000000..45f8baf08e --- /dev/null +++ b/tests/data/auth-basic-policy/spec/virtual-server-policy-single.yaml @@ -0,0 +1,22 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: virtual-server +spec: + host: virtual-server.example.com + policies: + - name: auth-basic-policy-valid + upstreams: + - name: backend2 + service: backend2-svc + port: 80 + - name: backend1 + service: backend1-svc + port: 80 + routes: + - path: "/backend1" + action: + pass: backend1 + - path: "/backend2" + action: + pass: backend2 \ No newline at end of file diff --git a/tests/suite/test_auth_basic_policies.py b/tests/suite/test_auth_basic_policies.py new file mode 100644 index 0000000000..dd6da6a518 --- /dev/null +++ b/tests/suite/test_auth_basic_policies.py @@ -0,0 +1,513 @@ +import pytest, requests +from base64 import b64encode +from kubernetes.client.rest import ApiException +from suite.resources_utils import ( + wait_before_test, + replace_configmap_from_yaml, + create_secret_from_yaml, + delete_secret, + replace_secret, +) +from suite.custom_resources_utils import ( + read_custom_resource, +) +from suite.vs_vsr_resources_utils import ( + delete_virtual_server, + create_virtual_server_from_yaml, + delete_and_create_vs_from_yaml, +) +from suite.policy_resources_utils import ( + create_policy_from_yaml, + delete_policy, + read_policy, +) +from settings import TEST_DATA, DEPLOYMENTS + +std_vs_src = f"{TEST_DATA}/virtual-server/standard/virtual-server.yaml" +htpasswd_sec_valid_src = f"{TEST_DATA}/auth-basic-policy/secret/htpasswd-secret-valid.yaml" +htpasswd_sec_invalid_src = f"{TEST_DATA}/auth-basic-policy/secret/htpasswd-secret-invalid.yaml" +htpasswd_sec_valid_empty_src = f"{TEST_DATA}/auth-basic-policy/secret/htpasswd-secret-valid-empty.yaml" +auth_basic_pol_valid_src = f"{TEST_DATA}/auth-basic-policy/policies/auth-basic-policy-valid.yaml" +auth_basic_pol_multi_src = f"{TEST_DATA}/auth-basic-policy/policies/auth-basic-policy-valid-multi.yaml" +auth_basic_vs_single_src = f"{TEST_DATA}/auth-basic-policy/spec/virtual-server-policy-single.yaml" +auth_basic_vs_single_invalid_pol_src = ( + f"{TEST_DATA}/auth-basic-policy/spec/virtual-server-policy-single-invalid-pol.yaml" +) +auth_basic_vs_multi_1_src = f"{TEST_DATA}/auth-basic-policy/spec/virtual-server-policy-multi-1.yaml" +auth_basic_vs_multi_2_src = f"{TEST_DATA}/auth-basic-policy/spec/virtual-server-policy-multi-2.yaml" +auth_basic_pol_invalid_src = f"{TEST_DATA}/auth-basic-policy/policies/auth-basic-policy-invalid.yaml" +auth_basic_pol_invalid_sec_src = f"{TEST_DATA}/auth-basic-policy/policies/auth-basic-policy-invalid-secret.yaml" +auth_basic_vs_single_invalid_sec_src = ( + f"{TEST_DATA}/auth-basic-policy/spec/virtual-server-policy-single-invalid-secret.yaml" +) +auth_basic_vs_override_route = f"{TEST_DATA}/auth-basic-policy/route-subroute/virtual-server-override-route.yaml" +auth_basic_vs_override_spec_route_1 = ( + f"{TEST_DATA}/auth-basic-policy/route-subroute/virtual-server-override-spec-route-1.yaml" +) +auth_basic_vs_override_spec_route_2 = ( + f"{TEST_DATA}/auth-basic-policy/route-subroute/virtual-server-override-spec-route-2.yaml" +) +valid_credentials_list = [ + f"{TEST_DATA}/auth-basic-policy/credentials.txt", + f"{TEST_DATA}/auth-basic-policy/credentials2.txt", +] +invalid_credentials_list = [ + f"{TEST_DATA}/auth-basic-policy/invalid-credentials.txt", + f"{TEST_DATA}/auth-basic-policy/invalid-credentials-no-pwd.txt", + f"{TEST_DATA}/auth-basic-policy/invalid-credentials-no-user.txt", + f"{TEST_DATA}/auth-basic-policy/invalid-credentials-pwd.txt", + f"{TEST_DATA}/auth-basic-policy/invalid-credentials-user.txt", +] +valid_credentials = valid_credentials_list[0] + +def to_base64(b64_string): + return b64encode( + b64_string.encode('ascii') + ).decode('ascii') + +@pytest.mark.policies +@pytest.mark.parametrize( + "crd_ingress_controller, virtual_server_setup", + [ + ( + { + "type": "complete", + "extra_args": [ + f"-enable-custom-resources", + f"-enable-preview-policies", + f"-enable-leader-election=false", + ], + }, + {"example": "virtual-server", "app_type": "simple",}, + ) + ], + indirect=True, +) +class TestAuthBasicPolicies: + def setup_single_policy(self, kube_apis, test_namespace, credentials, secret, policy, vs_host): + print(f"Create htpasswd secret") + secret_name = create_secret_from_yaml(kube_apis.v1, test_namespace, secret) + + print(f"Create auth basic policy") + pol_name = create_policy_from_yaml(kube_apis.custom_objects, policy, test_namespace) + wait_before_test() + + # generate header without auth + if credentials == None : + return secret_name, pol_name, {"host": vs_host} + + with open(credentials, "r") as file: + data = file.readline() + headers = {"host": vs_host, "authorization": f"Basic {to_base64(data)}"} + + return secret_name, pol_name, headers + + def setup_multiple_policies( + self, kube_apis, test_namespace, credentials, secret_list, policy_1, policy_2, vs_host + ): + print(f"Create {len(secret_list)} htpasswd secrets") + # secret_name = create_secret_from_yaml(kube_apis.v1, test_namespace, secret) + secret_name_list = [] + for secret in secret_list: + secret_name_list.append( + create_secret_from_yaml(kube_apis.v1, test_namespace, secret) + ) + + print(f"Create auth basic policy #1") + pol_name_1 = create_policy_from_yaml(kube_apis.custom_objects, policy_1, test_namespace) + print(f"Create auth basic policy #2") + pol_name_2 = create_policy_from_yaml(kube_apis.custom_objects, policy_2, test_namespace) + wait_before_test() + + with open(credentials, "r") as file: + data = file.readline() + headers = {"host": vs_host, "authorization": f"Basic {to_base64(data)}"} + + return secret_name_list, pol_name_1, pol_name_2, headers + + @pytest.mark.parametrize("credentials", valid_credentials_list + invalid_credentials_list + [None] ) + def test_auth_basic_policy_credentials( + self, kube_apis, crd_ingress_controller, virtual_server_setup, test_namespace, credentials, + ): + """ + Test auth-basic-policy with no credentials, valid credentials and invalid credentials + """ + secret, pol_name, headers = self.setup_single_policy( + kube_apis, + test_namespace, + credentials, + htpasswd_sec_valid_src, + auth_basic_pol_valid_src, + virtual_server_setup.vs_host, + ) + + print(f"Patch vs with policy: {auth_basic_vs_single_src}") + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + auth_basic_vs_single_src, + virtual_server_setup.namespace, + ) + wait_before_test() + + resp = requests.get(virtual_server_setup.backend_1_url, headers=headers) + print(resp.status_code) + + delete_policy(kube_apis.custom_objects, pol_name, test_namespace) + delete_secret(kube_apis.v1, secret, test_namespace) + + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + std_vs_src, + virtual_server_setup.namespace, + ) + + if credentials in valid_credentials_list: + assert resp.status_code == 200 + assert f"Request ID:" in resp.text + else: + assert resp.status_code == 401 + assert f"Authorization Required" in resp.text + + @pytest.mark.parametrize("htpasswd_secret", [htpasswd_sec_valid_src, htpasswd_sec_invalid_src]) + def test_auth_basic_policy_secret( + self, kube_apis, crd_ingress_controller, virtual_server_setup, test_namespace, htpasswd_secret, + ): + """ + Test auth-basic-policy with a valid and an invalid secret + """ + if htpasswd_secret == htpasswd_sec_valid_src: + pol = auth_basic_pol_valid_src + vs = auth_basic_vs_single_src + elif htpasswd_secret == htpasswd_sec_invalid_src: + pol = auth_basic_pol_invalid_sec_src + vs = auth_basic_vs_single_invalid_sec_src + else: + pytest.fail("Invalid configuration") + secret, pol_name, headers = self.setup_single_policy( + kube_apis, test_namespace, valid_credentials, htpasswd_secret, pol, virtual_server_setup.vs_host, + ) + + print(f"Patch vs with policy: {pol}") + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + vs, + virtual_server_setup.namespace, + ) + wait_before_test() + + resp = requests.get(virtual_server_setup.backend_1_url, headers=headers) + print(resp.status_code) + + crd_info = read_custom_resource( + kube_apis.custom_objects, + virtual_server_setup.namespace, + "virtualservers", + virtual_server_setup.vs_name, + ) + delete_policy(kube_apis.custom_objects, pol_name, test_namespace) + delete_secret(kube_apis.v1, secret, test_namespace) + + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + std_vs_src, + virtual_server_setup.namespace, + ) + + if htpasswd_secret == htpasswd_sec_valid_src: + assert resp.status_code == 200 + assert f"Request ID:" in resp.text + assert crd_info["status"]["state"] == "Valid" + elif htpasswd_secret == htpasswd_sec_invalid_src: + assert resp.status_code == 500 + assert f"Internal Server Error" in resp.text + assert crd_info["status"]["state"] == "Warning" + else: + pytest.fail(f"Not a valid case or parameter") + + @pytest.mark.smoke + @pytest.mark.parametrize("policy", [auth_basic_pol_valid_src, auth_basic_pol_invalid_src]) + def test_auth_basic_policy( + self, kube_apis, crd_ingress_controller, virtual_server_setup, test_namespace, policy, + ): + """ + Test auth-basic-policy with a valid and an invalid policy + """ + secret, pol_name, headers = self.setup_single_policy( + kube_apis, + test_namespace, + valid_credentials, + htpasswd_sec_valid_src, + policy, + virtual_server_setup.vs_host, + ) + + print(f"Patch vs with policy: {policy}") + policy_info = read_custom_resource(kube_apis.custom_objects, test_namespace, "policies", pol_name) + if policy == auth_basic_pol_valid_src: + vs_src = auth_basic_vs_single_src + assert ( + policy_info["status"] + and policy_info["status"]["reason"] == "AddedOrUpdated" + and policy_info["status"]["state"] == "Valid" + ) + elif policy == auth_basic_pol_invalid_src: + vs_src = auth_basic_vs_single_invalid_pol_src + assert ( + policy_info["status"] + and policy_info["status"]["reason"] == "Rejected" + and policy_info["status"]["state"] == "Invalid" + ) + else: + pytest.fail("Invalid configuration") + + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + vs_src, + virtual_server_setup.namespace, + ) + wait_before_test() + resp = requests.get(virtual_server_setup.backend_1_url, headers=headers) + print(resp.status_code) + crd_info = read_custom_resource( + kube_apis.custom_objects, + virtual_server_setup.namespace, + "virtualservers", + virtual_server_setup.vs_name, + ) + delete_policy(kube_apis.custom_objects, pol_name, test_namespace) + delete_secret(kube_apis.v1, secret, test_namespace) + + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + std_vs_src, + virtual_server_setup.namespace, + ) + + if policy == auth_basic_pol_valid_src: + assert resp.status_code == 200 + assert f"Request ID:" in resp.text + assert crd_info["status"]["state"] == "Valid" + elif policy == auth_basic_pol_invalid_src: + assert resp.status_code == 500 + assert f"Internal Server Error" in resp.text + assert crd_info["status"]["state"] == "Warning" + else: + pytest.fail(f"Not a valid case or parameter") + + def test_auth_basic_policy_delete_secret( + self, kube_apis, crd_ingress_controller, virtual_server_setup, test_namespace, + ): + """ + Test if requests result in 500 when secret is deleted + """ + secret, pol_name, headers = self.setup_single_policy( + kube_apis, + test_namespace, + valid_credentials, + htpasswd_sec_valid_src, + auth_basic_pol_valid_src, + virtual_server_setup.vs_host, + ) + + print(f"Patch vs with policy: {auth_basic_pol_valid_src}") + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + auth_basic_vs_single_src, + virtual_server_setup.namespace, + ) + wait_before_test() + + resp1 = requests.get(virtual_server_setup.backend_1_url, headers=headers) + print(resp1.status_code) + + delete_secret(kube_apis.v1, secret, test_namespace) + resp2 = requests.get(virtual_server_setup.backend_1_url, headers=headers) + print(resp2.status_code) + + delete_policy(kube_apis.custom_objects, pol_name, test_namespace) + + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + std_vs_src, + virtual_server_setup.namespace, + ) + + assert resp1.status_code == 200 + assert resp2.status_code == 500 + + def test_auth_basic_policy_delete_policy( + self, kube_apis, crd_ingress_controller, virtual_server_setup, test_namespace, + ): + """ + Test if requests result in 500 when policy is deleted + """ + secret, pol_name, headers = self.setup_single_policy( + kube_apis, + test_namespace, + valid_credentials, + htpasswd_sec_valid_src, + auth_basic_pol_valid_src, + virtual_server_setup.vs_host, + ) + + print(f"Patch vs with policy: {auth_basic_pol_valid_src}") + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + auth_basic_vs_single_src, + virtual_server_setup.namespace, + ) + wait_before_test() + + resp1 = requests.get(virtual_server_setup.backend_1_url, headers=headers) + print(resp1.status_code) + + delete_policy(kube_apis.custom_objects, pol_name, test_namespace) + + resp2 = requests.get(virtual_server_setup.backend_1_url, headers=headers) + print(resp2.status_code) + + delete_secret(kube_apis.v1, secret, test_namespace) + + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + std_vs_src, + virtual_server_setup.namespace, + ) + + assert resp1.status_code == 200 + assert resp2.status_code == 500 + + def test_auth_basic_policy_override( + self, kube_apis, crd_ingress_controller, virtual_server_setup, test_namespace, + ): + """ + Test if first reference to a policy in the same context takes precedence + """ + secret_list, pol_name_1, pol_name_2, headers = self.setup_multiple_policies( + kube_apis, + test_namespace, + valid_credentials, + [ htpasswd_sec_valid_src, htpasswd_sec_valid_empty_src ], + auth_basic_pol_valid_src, + auth_basic_pol_multi_src, + virtual_server_setup.vs_host, + ) + + print(f"Patch vs with multiple policy in spec context") + print(f"Patch vs with policy in order: {auth_basic_pol_multi_src} and {auth_basic_pol_valid_src}") + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + auth_basic_vs_multi_1_src, + virtual_server_setup.namespace, + ) + wait_before_test() + + resp1 = requests.get(virtual_server_setup.backend_1_url, headers=headers) + print(resp1.status_code) + + print(f"Patch vs with policy in order: {auth_basic_pol_valid_src} and {auth_basic_pol_multi_src}") + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + auth_basic_vs_multi_2_src, + virtual_server_setup.namespace, + ) + wait_before_test() + resp2 = requests.get(virtual_server_setup.backend_1_url, headers=headers) + print(resp2.status_code) + + print(f"Patch vs with multiple policy in route context") + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + auth_basic_vs_override_route, + virtual_server_setup.namespace, + ) + wait_before_test() + resp3 = requests.get(virtual_server_setup.backend_1_url, headers=headers) + print(resp3.status_code) + + delete_policy(kube_apis.custom_objects, pol_name_1, test_namespace) + delete_policy(kube_apis.custom_objects, pol_name_2, test_namespace) + for secret in secret_list: + delete_secret(kube_apis.v1, secret, test_namespace) + + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + std_vs_src, + virtual_server_setup.namespace, + ) + + assert ( + resp1.status_code == 401 + ) # 401 unauthorized, since no credentials are attached to policy in spec context + assert resp2.status_code == 200 + assert ( + resp3.status_code == 401 + ) # 401 unauthorized, since no credentials are attached to policy in route context + + def test_auth_basic_policy_override_spec( + self, kube_apis, crd_ingress_controller, virtual_server_setup, test_namespace, + ): + """ + Test if policy reference in route takes precedence over policy in spec + """ + secret_list, pol_name_1, pol_name_2, headers = self.setup_multiple_policies( + kube_apis, + test_namespace, + valid_credentials, + [ htpasswd_sec_valid_src, htpasswd_sec_valid_empty_src ], + auth_basic_pol_valid_src, + auth_basic_pol_multi_src, + virtual_server_setup.vs_host, + ) + + print(f"Patch vs with invalid policy in route and valid policy in spec") + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + auth_basic_vs_override_spec_route_1, + virtual_server_setup.namespace, + ) + wait_before_test() + + resp1 = requests.get(virtual_server_setup.backend_1_url, headers=headers) + print(resp1.status_code) + + print(f"Patch vs with valid policy in route and invalid policy in spec") + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + auth_basic_vs_override_spec_route_2, + virtual_server_setup.namespace, + ) + wait_before_test() + resp2 = requests.get(virtual_server_setup.backend_1_url, headers=headers) + print(resp2.status_code) + + delete_policy(kube_apis.custom_objects, pol_name_1, test_namespace) + delete_policy(kube_apis.custom_objects, pol_name_2, test_namespace) + for secret in secret_list: + delete_secret(kube_apis.v1, secret, test_namespace) + + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + std_vs_src, + virtual_server_setup.namespace, + ) + + assert resp1.status_code == 401 # 401 unauthorized, since no credentials are attached to policy + assert resp2.status_code == 200 diff --git a/tests/suite/test_auth_basic_policies_vsr.py b/tests/suite/test_auth_basic_policies_vsr.py new file mode 100644 index 0000000000..52f78e6b9e --- /dev/null +++ b/tests/suite/test_auth_basic_policies_vsr.py @@ -0,0 +1,565 @@ +import pytest, requests +from base64 import b64encode +from kubernetes.client.rest import ApiException +from suite.resources_utils import ( + wait_before_test, + replace_configmap_from_yaml, + create_secret_from_yaml, + delete_secret, + replace_secret, +) +from suite.custom_resources_utils import ( + read_custom_resource, +) +from suite.policy_resources_utils import ( + create_policy_from_yaml, + delete_policy, + read_policy, +) +from suite.vs_vsr_resources_utils import ( + delete_virtual_server, + patch_virtual_server_from_yaml, + patch_v_s_route_from_yaml, +) +from settings import TEST_DATA, DEPLOYMENTS + +std_vs_src = f"{TEST_DATA}/virtual-server-route/standard/virtual-server.yaml" +std_vsr_src = f"{TEST_DATA}/virtual-server-route/route-multiple.yaml" +htpasswd_sec_valid_src = f"{TEST_DATA}/auth-basic-policy/secret/htpasswd-secret-valid.yaml" +htpasswd_sec_invalid_src = f"{TEST_DATA}/auth-basic-policy/secret/htpasswd-secret-invalid.yaml" +htpasswd_sec_valid_empty_src = f"{TEST_DATA}/auth-basic-policy/secret/htpasswd-secret-valid-empty.yaml" +auth_basic_pol_valid_src = f"{TEST_DATA}/auth-basic-policy/policies/auth-basic-policy-valid.yaml" +auth_basic_pol_multi_src = f"{TEST_DATA}/auth-basic-policy/policies/auth-basic-policy-valid-multi.yaml" +auth_basic_pol_invalid_src = f"{TEST_DATA}/auth-basic-policy/policies/auth-basic-policy-invalid.yaml" +auth_basic_pol_invalid_sec_src = f"{TEST_DATA}/auth-basic-policy/policies/auth-basic-policy-invalid-secret.yaml" +auth_basic_vsr_invalid_src = ( + f"{TEST_DATA}/auth-basic-policy/route-subroute/virtual-server-route-invalid-subroute.yaml" +) +auth_basic_vsr_invalid_sec_src = ( + f"{TEST_DATA}/auth-basic-policy/route-subroute/virtual-server-route-invalid-subroute-secret.yaml" +) +auth_basic_vsr_override_src = ( + f"{TEST_DATA}/auth-basic-policy/route-subroute/virtual-server-route-override-subroute.yaml" +) +auth_basic_vsr_valid_src = ( + f"{TEST_DATA}/auth-basic-policy/route-subroute/virtual-server-route-valid-subroute.yaml" +) +auth_basic_vsr_valid_multi_src = ( + f"{TEST_DATA}/auth-basic-policy/route-subroute/virtual-server-route-valid-subroute-multi.yaml" +) +auth_basic_vs_override_spec_src = ( + f"{TEST_DATA}/auth-basic-policy/route-subroute/virtual-server-vsr-spec-override.yaml" +) +auth_basic_vs_override_route_src = ( + f"{TEST_DATA}/auth-basic-policy/route-subroute/virtual-server-vsr-route-override.yaml" +) +valid_credentials = f"{TEST_DATA}/auth-basic-policy/credentials.txt" +invalid_credentials = f"{TEST_DATA}/auth-basic-policy/invalid-credentials.txt" + +def to_base64(b64_string): + return b64encode( + b64_string.encode('ascii') + ).decode('ascii') + +@pytest.mark.policies +@pytest.mark.parametrize( + "crd_ingress_controller, v_s_route_setup", + [ + ( + { + "type": "complete", + "extra_args": [ + f"-enable-custom-resources", + f"-enable-preview-policies", + f"-enable-leader-election=false", + ], + }, + {"example": "virtual-server-route"}, + ) + ], + indirect=True, +) +class TestAuthBasicPoliciesVsr: + def setup_single_policy(self, kube_apis, namespace, credentials, secret, policy, vs_host): + print(f"Create htpasswd secret") + secret_name = create_secret_from_yaml(kube_apis.v1, namespace, secret) + + print(f"Create auth basic policy") + pol_name = create_policy_from_yaml(kube_apis.custom_objects, policy, namespace) + + wait_before_test() + + # generate header without auth + if credentials == None : + return secret_name, pol_name, {"host": vs_host} + + with open(credentials, "r") as file: + data = file.readline() + headers = {"host": vs_host, "authorization": f"Basic {to_base64(data)}"} + + return secret_name, pol_name, headers + + def setup_multiple_policies( + self, kube_apis, namespace, credentials, secret_list, policy_1, policy_2, vs_host + ): + print(f"Create {len(secret_list)} htpasswd secrets") + # secret_name = create_secret_from_yaml(kube_apis.v1, namespace, secret) + secret_name_list = [] + for secret in secret_list: + secret_name_list.append( + create_secret_from_yaml(kube_apis.v1, namespace, secret) + ) + + print(f"Create auth basic policy #1") + pol_name_1 = create_policy_from_yaml(kube_apis.custom_objects, policy_1, namespace) + print(f"Create auth basic policy #2") + pol_name_2 = create_policy_from_yaml(kube_apis.custom_objects, policy_2, namespace) + + wait_before_test() + with open(credentials, "r") as file: + data = file.readline() + headers = {"host": vs_host, "authorization": f"Basic {to_base64(data)}"} + + return secret_name_list, pol_name_1, pol_name_2, headers + + @pytest.mark.parametrize("credentials", [valid_credentials, invalid_credentials, None]) + def test_auth_basic_policy_credentials( + self, + kube_apis, + crd_ingress_controller, + v_s_route_app_setup, + v_s_route_setup, + test_namespace, + credentials, + ): + """ + Test auth-basic-policy with no credentials, valid credentials and invalid credentials + """ + req_url = f"http://{v_s_route_setup.public_endpoint.public_ip}:{v_s_route_setup.public_endpoint.port}" + secret, pol_name, headers = self.setup_single_policy( + kube_apis, + v_s_route_setup.route_m.namespace, + credentials, + htpasswd_sec_valid_src, + auth_basic_pol_valid_src, + v_s_route_setup.vs_host, + ) + + print(f"Patch vsr with policy: {auth_basic_vsr_valid_src}") + patch_v_s_route_from_yaml( + kube_apis.custom_objects, + v_s_route_setup.route_m.name, + auth_basic_vsr_valid_src, + v_s_route_setup.route_m.namespace, + ) + wait_before_test() + + resp = requests.get(f"{req_url}{v_s_route_setup.route_m.paths[0]}", headers=headers) + print(resp.status_code) + + delete_policy(kube_apis.custom_objects, pol_name, v_s_route_setup.route_m.namespace) + delete_secret(kube_apis.v1, secret, v_s_route_setup.route_m.namespace) + + patch_v_s_route_from_yaml( + kube_apis.custom_objects, + v_s_route_setup.route_m.name, + std_vsr_src, + v_s_route_setup.route_m.namespace, + ) + + if credentials == valid_credentials: + assert resp.status_code == 200 + assert f"Request ID:" in resp.text + else: + assert resp.status_code == 401 + assert f"Authorization Required" in resp.text + + @pytest.mark.parametrize("htpasswd_secret", [htpasswd_sec_valid_src, htpasswd_sec_invalid_src]) + def test_auth_basic_policy_secret( + self, + kube_apis, + crd_ingress_controller, + v_s_route_app_setup, + v_s_route_setup, + test_namespace, + htpasswd_secret, + ): + """ + Test auth-basic-policy with a valid and an invalid secret + """ + req_url = f"http://{v_s_route_setup.public_endpoint.public_ip}:{v_s_route_setup.public_endpoint.port}" + if htpasswd_secret == htpasswd_sec_valid_src: + pol = auth_basic_pol_valid_src + vsr = auth_basic_vsr_valid_src + elif htpasswd_secret == htpasswd_sec_invalid_src: + pol = auth_basic_pol_invalid_sec_src + vsr = auth_basic_vsr_invalid_sec_src + else: + pytest.fail(f"Not a valid case or parameter") + + secret, pol_name, headers = self.setup_single_policy( + kube_apis, + v_s_route_setup.route_m.namespace, + valid_credentials, + htpasswd_secret, + pol, + v_s_route_setup.vs_host, + ) + + print(f"Patch vsr with policy: {pol}") + patch_v_s_route_from_yaml( + kube_apis.custom_objects, + v_s_route_setup.route_m.name, + vsr, + v_s_route_setup.route_m.namespace, + ) + wait_before_test() + + resp = requests.get(f"{req_url}{v_s_route_setup.route_m.paths[0]}", headers=headers,) + print(resp.status_code) + + crd_info = read_custom_resource( + kube_apis.custom_objects, + v_s_route_setup.route_m.namespace, + "virtualserverroutes", + v_s_route_setup.route_m.name, + ) + delete_policy(kube_apis.custom_objects, pol_name, v_s_route_setup.route_m.namespace) + delete_secret(kube_apis.v1, secret, v_s_route_setup.route_m.namespace) + + patch_v_s_route_from_yaml( + kube_apis.custom_objects, + v_s_route_setup.route_m.name, + std_vsr_src, + v_s_route_setup.route_m.namespace, + ) + + if htpasswd_secret == htpasswd_sec_valid_src: + assert resp.status_code == 200 + assert f"Request ID:" in resp.text + assert crd_info["status"]["state"] == "Valid" + elif htpasswd_secret == htpasswd_sec_invalid_src: + assert resp.status_code == 500 + assert f"Internal Server Error" in resp.text + assert crd_info["status"]["state"] == "Warning" + else: + pytest.fail(f"Not a valid case or parameter") + + @pytest.mark.smoke + @pytest.mark.parametrize("policy", [auth_basic_pol_valid_src, auth_basic_pol_invalid_src]) + def test_auth_basic_policy( + self, + kube_apis, + crd_ingress_controller, + v_s_route_app_setup, + v_s_route_setup, + test_namespace, + policy, + ): + """ + Test auth-basic-policy with a valid and an invalid policy + """ + req_url = f"http://{v_s_route_setup.public_endpoint.public_ip}:{v_s_route_setup.public_endpoint.port}" + if policy == auth_basic_pol_valid_src: + vsr = auth_basic_vsr_valid_src + elif policy == auth_basic_pol_invalid_src: + vsr = auth_basic_vsr_invalid_src + else: + pytest.fail(f"Not a valid case or parameter") + + secret, pol_name, headers = self.setup_single_policy( + kube_apis, + v_s_route_setup.route_m.namespace, + valid_credentials, + htpasswd_sec_valid_src, + policy, + v_s_route_setup.vs_host, + ) + + print(f"Patch vsr with policy: {policy}") + patch_v_s_route_from_yaml( + kube_apis.custom_objects, + v_s_route_setup.route_m.name, + vsr, + v_s_route_setup.route_m.namespace, + ) + wait_before_test() + + resp = requests.get(f"{req_url}{v_s_route_setup.route_m.paths[0]}", headers=headers) + print(resp.status_code) + policy_info = read_custom_resource( + kube_apis.custom_objects, v_s_route_setup.route_m.namespace, "policies", pol_name + ) + crd_info = read_custom_resource( + kube_apis.custom_objects, + v_s_route_setup.route_m.namespace, + "virtualserverroutes", + v_s_route_setup.route_m.name, + ) + delete_policy(kube_apis.custom_objects, pol_name, v_s_route_setup.route_m.namespace) + delete_secret(kube_apis.v1, secret, v_s_route_setup.route_m.namespace) + + patch_v_s_route_from_yaml( + kube_apis.custom_objects, + v_s_route_setup.route_m.name, + std_vsr_src, + v_s_route_setup.route_m.namespace, + ) + + if policy == auth_basic_pol_valid_src: + assert resp.status_code == 200 + assert f"Request ID:" in resp.text + assert crd_info["status"]["state"] == "Valid" + assert ( + policy_info["status"] + and policy_info["status"]["reason"] == "AddedOrUpdated" + and policy_info["status"]["state"] == "Valid" + ) + elif policy == auth_basic_pol_invalid_src: + assert resp.status_code == 500 + assert f"Internal Server Error" in resp.text + assert crd_info["status"]["state"] == "Warning" + assert ( + policy_info["status"] + and policy_info["status"]["reason"] == "Rejected" + and policy_info["status"]["state"] == "Invalid" + ) + else: + pytest.fail(f"Not a valid case or parameter") + + def test_auth_basic_policy_delete_secret( + self, + kube_apis, + crd_ingress_controller, + v_s_route_app_setup, + v_s_route_setup, + test_namespace, + ): + """ + Test if requests result in 500 when secret is deleted + """ + req_url = f"http://{v_s_route_setup.public_endpoint.public_ip}:{v_s_route_setup.public_endpoint.port}" + secret, pol_name, headers = self.setup_single_policy( + kube_apis, + v_s_route_setup.route_m.namespace, + valid_credentials, + htpasswd_sec_valid_src, + auth_basic_pol_valid_src, + v_s_route_setup.vs_host, + ) + + print(f"Patch vsr with policy: {auth_basic_pol_valid_src}") + patch_v_s_route_from_yaml( + kube_apis.custom_objects, + v_s_route_setup.route_m.name, + auth_basic_vsr_valid_src, + v_s_route_setup.route_m.namespace, + ) + wait_before_test() + + resp1 = requests.get(f"{req_url}{v_s_route_setup.route_m.paths[0]}", headers=headers,) + print(resp1.status_code) + + delete_secret(kube_apis.v1, secret, v_s_route_setup.route_m.namespace) + resp2 = requests.get(f"{req_url}{v_s_route_setup.route_m.paths[0]}", headers=headers,) + print(resp2.status_code) + crd_info = read_custom_resource( + kube_apis.custom_objects, + v_s_route_setup.route_m.namespace, + "virtualserverroutes", + v_s_route_setup.route_m.name, + ) + delete_policy(kube_apis.custom_objects, pol_name, v_s_route_setup.route_m.namespace) + + patch_v_s_route_from_yaml( + kube_apis.custom_objects, + v_s_route_setup.route_m.name, + std_vsr_src, + v_s_route_setup.route_m.namespace, + ) + assert resp1.status_code == 200 + assert f"Request ID:" in resp1.text + assert crd_info["status"]["state"] == "Warning" + assert ( + f"references an invalid secret {v_s_route_setup.route_m.namespace}/{secret}: secret doesn't exist or of an unsupported type" + in crd_info["status"]["message"] + ) + assert resp2.status_code == 500 + assert f"Internal Server Error" in resp2.text + + def test_auth_basic_policy_delete_policy( + self, + kube_apis, + crd_ingress_controller, + v_s_route_app_setup, + v_s_route_setup, + test_namespace, + ): + """ + Test if requests result in 500 when policy is deleted + """ + req_url = f"http://{v_s_route_setup.public_endpoint.public_ip}:{v_s_route_setup.public_endpoint.port}" + secret, pol_name, headers = self.setup_single_policy( + kube_apis, + v_s_route_setup.route_m.namespace, + valid_credentials, + htpasswd_sec_valid_src, + auth_basic_pol_valid_src, + v_s_route_setup.vs_host, + ) + + print(f"Patch vsr with policy: {auth_basic_pol_valid_src}") + patch_v_s_route_from_yaml( + kube_apis.custom_objects, + v_s_route_setup.route_m.name, + auth_basic_vsr_valid_src, + v_s_route_setup.route_m.namespace, + ) + wait_before_test() + + resp1 = requests.get(f"{req_url}{v_s_route_setup.route_m.paths[0]}", headers=headers,) + print(resp1.status_code) + delete_policy(kube_apis.custom_objects, pol_name, v_s_route_setup.route_m.namespace) + + resp2 = requests.get(f"{req_url}{v_s_route_setup.route_m.paths[0]}", headers=headers,) + print(resp2.status_code) + crd_info = read_custom_resource( + kube_apis.custom_objects, + v_s_route_setup.route_m.namespace, + "virtualserverroutes", + v_s_route_setup.route_m.name, + ) + delete_secret(kube_apis.v1, secret, v_s_route_setup.route_m.namespace) + + patch_v_s_route_from_yaml( + kube_apis.custom_objects, + v_s_route_setup.route_m.name, + std_vsr_src, + v_s_route_setup.route_m.namespace, + ) + assert resp1.status_code == 200 + assert f"Request ID:" in resp1.text + assert crd_info["status"]["state"] == "Warning" + assert ( + f"{v_s_route_setup.route_m.namespace}/{pol_name} is missing" + in crd_info["status"]["message"] + ) + assert resp2.status_code == 500 + assert f"Internal Server Error" in resp2.text + + def test_auth_basic_policy_override( + self, + kube_apis, + crd_ingress_controller, + v_s_route_app_setup, + v_s_route_setup, + test_namespace, + ): + """ + Test if first reference to a policy in the same context(subroute) takes precedence, + i.e. in this case, policy with empty htpasswd over policy with htpasswd. + """ + req_url = f"http://{v_s_route_setup.public_endpoint.public_ip}:{v_s_route_setup.public_endpoint.port}" + secret_list, pol_name_1, pol_name_2, headers = self.setup_multiple_policies( + kube_apis, + v_s_route_setup.route_m.namespace, + valid_credentials, + [ htpasswd_sec_valid_src, htpasswd_sec_valid_empty_src ], + auth_basic_pol_valid_src, + auth_basic_pol_multi_src, + v_s_route_setup.vs_host, + ) + + print(f"Patch vsr with policies: {auth_basic_pol_valid_src}") + patch_v_s_route_from_yaml( + kube_apis.custom_objects, + v_s_route_setup.route_m.name, + auth_basic_vsr_override_src, + v_s_route_setup.route_m.namespace, + ) + wait_before_test() + + resp = requests.get(f"{req_url}{v_s_route_setup.route_m.paths[0]}", headers=headers) + print(resp.status_code) + + crd_info = read_custom_resource( + kube_apis.custom_objects, + v_s_route_setup.route_m.namespace, + "virtualserverroutes", + v_s_route_setup.route_m.name, + ) + delete_policy(kube_apis.custom_objects, pol_name_1, v_s_route_setup.route_m.namespace) + delete_policy(kube_apis.custom_objects, pol_name_2, v_s_route_setup.route_m.namespace) + for secret in secret_list: + delete_secret(kube_apis.v1, secret, v_s_route_setup.route_m.namespace) + + patch_v_s_route_from_yaml( + kube_apis.custom_objects, + v_s_route_setup.route_m.name, + std_vsr_src, + v_s_route_setup.route_m.namespace, + ) + assert resp.status_code == 401 + assert f"Authorization Required" in resp.text + assert ( + f"Multiple basic auth policies in the same context is not valid." + in crd_info["status"]["message"] + ) + + @pytest.mark.parametrize("vs_src", [auth_basic_vs_override_route_src, auth_basic_vs_override_spec_src]) + def test_auth_basic_policy_override_vs_vsr( + self, + kube_apis, + crd_ingress_controller, + v_s_route_app_setup, + v_s_route_setup, + test_namespace, + vs_src, + ): + """ + Test if policy specified in vsr:subroute (policy without $httptoken) takes preference over policy specified in: + 1. vs:spec (policy with $httptoken) + 2. vs:route (policy with $httptoken) + """ + req_url = f"http://{v_s_route_setup.public_endpoint.public_ip}:{v_s_route_setup.public_endpoint.port}" + secret_list, pol_name_1, pol_name_2, headers = self.setup_multiple_policies( + kube_apis, + v_s_route_setup.route_m.namespace, + valid_credentials, + [ htpasswd_sec_valid_src, htpasswd_sec_valid_empty_src ], + auth_basic_pol_valid_src, + auth_basic_pol_multi_src, + v_s_route_setup.vs_host, + ) + + print(f"Patch vsr with policies: {auth_basic_pol_valid_src}") + patch_v_s_route_from_yaml( + kube_apis.custom_objects, + v_s_route_setup.route_m.name, + auth_basic_vsr_valid_multi_src, + v_s_route_setup.route_m.namespace, + ) + patch_virtual_server_from_yaml( + kube_apis.custom_objects, v_s_route_setup.vs_name, vs_src, v_s_route_setup.namespace, + ) + wait_before_test() + + resp = requests.get(f"{req_url}{v_s_route_setup.route_m.paths[0]}", headers=headers,) + print(resp.status_code) + + delete_policy(kube_apis.custom_objects, pol_name_1, v_s_route_setup.route_m.namespace) + delete_policy(kube_apis.custom_objects, pol_name_2, v_s_route_setup.route_m.namespace) + for secret in secret_list: + delete_secret(kube_apis.v1, secret, v_s_route_setup.route_m.namespace) + + patch_v_s_route_from_yaml( + kube_apis.custom_objects, + v_s_route_setup.route_m.name, + std_vsr_src, + v_s_route_setup.route_m.namespace, + ) + patch_virtual_server_from_yaml( + kube_apis.custom_objects, v_s_route_setup.vs_name, std_vs_src, v_s_route_setup.namespace + ) + assert resp.status_code == 401 + assert f"Authorization Required" in resp.text From 6f3e363c86189556267fce72b21ff2c89e1d1611 Mon Sep 17 00:00:00 2001 From: Remi COMBE Date: Thu, 21 Apr 2022 09:35:27 +0200 Subject: [PATCH 4/6] add integration tests for HTTP Basic authentication ingress annotations --- .../auth-basic-master-secret-updated.yaml | 9 + .../auth-basic-master-secret.yaml | 9 + .../auth-basic-minion-secret-updated.yaml | 9 + .../auth-basic-minion-secret.yaml | 9 + .../auth-basic-auth-master-credentials.txt | 1 + .../auth-basic-auth-minion-credentials.txt | 1 + .../mergeable/auth-basic-auth-ingress.yaml | 55 ++++++ .../auth-basic-secret-invalid.yaml | 9 + .../auth-basic-secret-updated.yaml | 9 + .../auth-basic-secrets/auth-basic-secret.yaml | 9 + .../credentials/credentials.txt | 1 + .../mergeable/auth-basic-secrets-ingress.yaml | 52 ++++++ .../standard/auth-basic-secrets-ingress.yaml | 27 +++ tests/suite/test_auth_basic_auth_mergeable.py | 165 ++++++++++++++++++ tests/suite/test_auth_basic_secrets.py | 115 ++++++++++++ 15 files changed, 480 insertions(+) create mode 100644 tests/data/auth-basic-auth-mergeable/auth-basic-master-secret-updated.yaml create mode 100644 tests/data/auth-basic-auth-mergeable/auth-basic-master-secret.yaml create mode 100644 tests/data/auth-basic-auth-mergeable/auth-basic-minion-secret-updated.yaml create mode 100644 tests/data/auth-basic-auth-mergeable/auth-basic-minion-secret.yaml create mode 100644 tests/data/auth-basic-auth-mergeable/credentials/auth-basic-auth-master-credentials.txt create mode 100644 tests/data/auth-basic-auth-mergeable/credentials/auth-basic-auth-minion-credentials.txt create mode 100644 tests/data/auth-basic-auth-mergeable/mergeable/auth-basic-auth-ingress.yaml create mode 100644 tests/data/auth-basic-secrets/auth-basic-secret-invalid.yaml create mode 100644 tests/data/auth-basic-secrets/auth-basic-secret-updated.yaml create mode 100644 tests/data/auth-basic-secrets/auth-basic-secret.yaml create mode 100644 tests/data/auth-basic-secrets/credentials/credentials.txt create mode 100644 tests/data/auth-basic-secrets/mergeable/auth-basic-secrets-ingress.yaml create mode 100644 tests/data/auth-basic-secrets/standard/auth-basic-secrets-ingress.yaml create mode 100644 tests/suite/test_auth_basic_auth_mergeable.py create mode 100644 tests/suite/test_auth_basic_secrets.py diff --git a/tests/data/auth-basic-auth-mergeable/auth-basic-master-secret-updated.yaml b/tests/data/auth-basic-auth-mergeable/auth-basic-master-secret-updated.yaml new file mode 100644 index 0000000000..f6a10f93a0 --- /dev/null +++ b/tests/data/auth-basic-auth-mergeable/auth-basic-master-secret-updated.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: auth-basic-master-htpasswd +type: nginx.org/htpasswd +stringData: + htpasswd: | + qux:$apr1$st218vzc$A3H7I83N9vLmczj73Byi3/ + # quux diff --git a/tests/data/auth-basic-auth-mergeable/auth-basic-master-secret.yaml b/tests/data/auth-basic-auth-mergeable/auth-basic-master-secret.yaml new file mode 100644 index 0000000000..048f36f46e --- /dev/null +++ b/tests/data/auth-basic-auth-mergeable/auth-basic-master-secret.yaml @@ -0,0 +1,9 @@ +kind: Secret +metadata: + name: auth-basic-master-htpasswd +apiVersion: v1 +type: nginx.org/htpasswd +stringData: + htpasswd: | + foo:$2y$10$mnb.J7DxTtC8/2EGRkmwsehTlNgQS0VbaryPr19aqIgI6IaukL77u + # bar diff --git a/tests/data/auth-basic-auth-mergeable/auth-basic-minion-secret-updated.yaml b/tests/data/auth-basic-auth-mergeable/auth-basic-minion-secret-updated.yaml new file mode 100644 index 0000000000..28647997f8 --- /dev/null +++ b/tests/data/auth-basic-auth-mergeable/auth-basic-minion-secret-updated.yaml @@ -0,0 +1,9 @@ +kind: Secret +metadata: + name: auth-basic-minion-htpasswd +apiVersion: v1 +type: nginx.org/htpasswd +stringData: + htpasswd: | + foo:$2y$10$mnb.J7DxTtC8/2EGRkmwsehTlNgQS0VbaryPr19aqIgI6IaukL77u + # bar diff --git a/tests/data/auth-basic-auth-mergeable/auth-basic-minion-secret.yaml b/tests/data/auth-basic-auth-mergeable/auth-basic-minion-secret.yaml new file mode 100644 index 0000000000..813b066f23 --- /dev/null +++ b/tests/data/auth-basic-auth-mergeable/auth-basic-minion-secret.yaml @@ -0,0 +1,9 @@ +kind: Secret +metadata: + name: auth-basic-minion-htpasswd +apiVersion: v1 +type: nginx.org/htpasswd +stringData: + htpasswd: | + qux:$apr1$st218vzc$A3H7I83N9vLmczj73Byi3/ + # quux diff --git a/tests/data/auth-basic-auth-mergeable/credentials/auth-basic-auth-master-credentials.txt b/tests/data/auth-basic-auth-mergeable/credentials/auth-basic-auth-master-credentials.txt new file mode 100644 index 0000000000..ed3b07faeb --- /dev/null +++ b/tests/data/auth-basic-auth-mergeable/credentials/auth-basic-auth-master-credentials.txt @@ -0,0 +1 @@ +foo:bar \ No newline at end of file diff --git a/tests/data/auth-basic-auth-mergeable/credentials/auth-basic-auth-minion-credentials.txt b/tests/data/auth-basic-auth-mergeable/credentials/auth-basic-auth-minion-credentials.txt new file mode 100644 index 0000000000..25735fbfdf --- /dev/null +++ b/tests/data/auth-basic-auth-mergeable/credentials/auth-basic-auth-minion-credentials.txt @@ -0,0 +1 @@ +qux:quux \ No newline at end of file diff --git a/tests/data/auth-basic-auth-mergeable/mergeable/auth-basic-auth-ingress.yaml b/tests/data/auth-basic-auth-mergeable/mergeable/auth-basic-auth-ingress.yaml new file mode 100644 index 0000000000..d14e08dae2 --- /dev/null +++ b/tests/data/auth-basic-auth-mergeable/mergeable/auth-basic-auth-ingress.yaml @@ -0,0 +1,55 @@ + +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: auth-basic-auth-ingress-master + annotations: + kubernetes.io/ingress.class: "nginx" + nginx.org/mergeable-ingress-type: "master" + nginx.org/basic-auth-secret: "auth-basic-master-htpasswd" + nginx.org/basic-auth-realm: "AuthBasic Auth Mergeable App" +spec: + rules: + - host: auth-basic-auth-mergeable.example.com +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: auth-basic-auth-ingress-backend1-minion + annotations: + kubernetes.io/ingress.class: "nginx" + nginx.org/mergeable-ingress-type: "minion" + nginx.org/basic-auth-secret: "auth-basic-minion-htpasswd" + nginx.org/basic-auth-realm: "Backend1" +spec: + rules: + - host: auth-basic-auth-mergeable.example.com + http: + paths: + - path: /backend1 + pathType: Prefix + backend: + service: + name: backend1-svc + port: + number: 80 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: auth-basic-auth-ingress-backend2-minion + annotations: + kubernetes.io/ingress.class: "nginx" + nginx.org/mergeable-ingress-type: "minion" +spec: + rules: + - host: auth-basic-auth-mergeable.example.com + http: + paths: + - path: /backend2 + pathType: Prefix + backend: + service: + name: backend2-svc + port: + number: 80 diff --git a/tests/data/auth-basic-secrets/auth-basic-secret-invalid.yaml b/tests/data/auth-basic-secrets/auth-basic-secret-invalid.yaml new file mode 100644 index 0000000000..01380399f6 --- /dev/null +++ b/tests/data/auth-basic-secrets/auth-basic-secret-invalid.yaml @@ -0,0 +1,9 @@ +kind: Secret +metadata: + name: auth-basic-secrets-key +apiVersion: v1 +type: nginx.org/htpasswd +stringData: + invalid-htpasswd: | + foo:$2y$10$mnb.J7DxTtC8/2EGRkmwsehTlNgQS0VbaryPr19aqIgI6IaukL77u + qux:$apr1$st218vzc$A3H7I83N9vLmczj73Byi3/ diff --git a/tests/data/auth-basic-secrets/auth-basic-secret-updated.yaml b/tests/data/auth-basic-secrets/auth-basic-secret-updated.yaml new file mode 100644 index 0000000000..a84745cf7d --- /dev/null +++ b/tests/data/auth-basic-secrets/auth-basic-secret-updated.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: auth-basic-secrets-key +type: nginx.org/htpasswd +stringData: + htpasswd: | + qux:$apr1$st218vzc$A3H7I83N9vLmczj73Byi3/ + # quux diff --git a/tests/data/auth-basic-secrets/auth-basic-secret.yaml b/tests/data/auth-basic-secrets/auth-basic-secret.yaml new file mode 100644 index 0000000000..f362ad3f5b --- /dev/null +++ b/tests/data/auth-basic-secrets/auth-basic-secret.yaml @@ -0,0 +1,9 @@ +kind: Secret +metadata: + name: auth-basic-secrets-key +apiVersion: v1 +type: nginx.org/htpasswd +stringData: + htpasswd: | + foo:$2y$10$e4CiBWaLq9JW93jV8r9CW.RE6fbsT3szmIsUhwqYuPfVlggXiBY76 + # bar diff --git a/tests/data/auth-basic-secrets/credentials/credentials.txt b/tests/data/auth-basic-secrets/credentials/credentials.txt new file mode 100644 index 0000000000..ed3b07faeb --- /dev/null +++ b/tests/data/auth-basic-secrets/credentials/credentials.txt @@ -0,0 +1 @@ +foo:bar \ No newline at end of file diff --git a/tests/data/auth-basic-secrets/mergeable/auth-basic-secrets-ingress.yaml b/tests/data/auth-basic-secrets/mergeable/auth-basic-secrets-ingress.yaml new file mode 100644 index 0000000000..829cfa03ab --- /dev/null +++ b/tests/data/auth-basic-secrets/mergeable/auth-basic-secrets-ingress.yaml @@ -0,0 +1,52 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: auth-basic-secrets-ingress-master + annotations: + kubernetes.io/ingress.class: "nginx" + nginx.org/mergeable-ingress-type: "master" +spec: + rules: + - host: auth-basic-secrets.example.com +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: auth-basic-secrets-ingress-backend1-minion + annotations: + kubernetes.io/ingress.class: "nginx" + nginx.org/mergeable-ingress-type: "minion" +spec: + rules: + - host: auth-basic-secrets.example.com + http: + paths: + - path: /backend1 + pathType: Prefix + backend: + service: + name: backend1-svc + port: + number: 80 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: auth-basic-secrets-ingress-backend2-minion + annotations: + kubernetes.io/ingress.class: "nginx" + nginx.org/mergeable-ingress-type: "minion" + nginx.org/basic-auth-secret: "auth-basic-secrets-key" + nginx.org/basic-auth-realm: "AuthBasic Secrets App" +spec: + rules: + - host: auth-basic-secrets.example.com + http: + paths: + - path: /backend2 + pathType: Prefix + backend: + service: + name: backend2-svc + port: + number: 80 diff --git a/tests/data/auth-basic-secrets/standard/auth-basic-secrets-ingress.yaml b/tests/data/auth-basic-secrets/standard/auth-basic-secrets-ingress.yaml new file mode 100644 index 0000000000..ec724d31e9 --- /dev/null +++ b/tests/data/auth-basic-secrets/standard/auth-basic-secrets-ingress.yaml @@ -0,0 +1,27 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: auth-basic-secrets-ingress + annotations: + kubernetes.io/ingress.class: "nginx" + nginx.org/basic-auth-secret: "auth-basic-secrets-key" + nginx.org/basic-auth-realm: "AuthBasic Secrets App" +spec: + rules: + - host: "auth-basic-secrets.example.com" + http: + paths: + - path: /backend2 + pathType: Prefix + backend: + service: + name: backend2-svc + port: + number: 80 + - path: /backend1 + pathType: Prefix + backend: + service: + name: backend1-svc + port: + number: 80 diff --git a/tests/suite/test_auth_basic_auth_mergeable.py b/tests/suite/test_auth_basic_auth_mergeable.py new file mode 100644 index 0000000000..9c81bcc34d --- /dev/null +++ b/tests/suite/test_auth_basic_auth_mergeable.py @@ -0,0 +1,165 @@ +import requests +import pytest + +from base64 import b64encode +from suite.fixtures import PublicEndpoint +from suite.resources_utils import create_secret_from_yaml, delete_secret, replace_secret,\ + ensure_connection_to_public_endpoint, wait_before_test +from suite.resources_utils import create_items_from_yaml, delete_items_from_yaml, create_example_app, delete_common_app +from suite.resources_utils import wait_until_all_pods_are_ready, is_secret_present +from suite.yaml_utils import get_first_ingress_host_from_yaml +from settings import TEST_DATA + +def to_base64(b64_string): + return b64encode( + b64_string.encode('ascii') + ).decode('ascii') + + +class AuthBasicAuthMergeableSetup: + """ + Encapsulate Auth Basic Auth Mergeable Minions Example details. + + Attributes: + public_endpoint (PublicEndpoint): + ingress_host (str): a hostname from Ingress resource + master_secret_name (str): + minion_secret_name (str): + credentials_dict ([]): a dictionnary of credentials for testing + """ + def __init__(self, public_endpoint: PublicEndpoint, ingress_host, master_secret_name, minion_secret_name, credentials_dict): + self.public_endpoint = public_endpoint + self.ingress_host = ingress_host + self.master_secret_name = master_secret_name + self.minion_secret_name = minion_secret_name + self.credentials_dict = credentials_dict + + +@pytest.fixture(scope="class") +def auth_basic_auth_setup(request, kube_apis, ingress_controller_endpoint, ingress_controller, test_namespace) -> AuthBasicAuthMergeableSetup: + credentials_dict = {"master": get_credentials_from_file("master"), "minion": get_credentials_from_file("minion")} + master_secret_name = create_secret_from_yaml(kube_apis.v1, test_namespace, + f"{TEST_DATA}/auth-basic-auth-mergeable/auth-basic-master-secret.yaml") + minion_secret_name = create_secret_from_yaml(kube_apis.v1, test_namespace, + f"{TEST_DATA}/auth-basic-auth-mergeable/auth-basic-minion-secret.yaml") + print("------------------------- Deploy Auth Basic Auth Mergeable Minions Example -----------------------------------") + create_items_from_yaml(kube_apis, f"{TEST_DATA}/auth-basic-auth-mergeable/mergeable/auth-basic-auth-ingress.yaml", test_namespace) + ingress_host = get_first_ingress_host_from_yaml(f"{TEST_DATA}/auth-basic-auth-mergeable/mergeable/auth-basic-auth-ingress.yaml") + create_example_app(kube_apis, "simple", test_namespace) + wait_until_all_pods_are_ready(kube_apis.v1, test_namespace) + ensure_connection_to_public_endpoint(ingress_controller_endpoint.public_ip, + ingress_controller_endpoint.port, + ingress_controller_endpoint.port_ssl) + wait_before_test(2) + + def fin(): + print("Delete Master Secret:") + if is_secret_present(kube_apis.v1, master_secret_name, test_namespace): + delete_secret(kube_apis.v1, master_secret_name, test_namespace) + + print("Delete Minion Secret:") + if is_secret_present(kube_apis.v1, minion_secret_name, test_namespace): + delete_secret(kube_apis.v1, minion_secret_name, test_namespace) + + print("Clean up the Auth Basic Auth Mergeable Minions Application:") + delete_common_app(kube_apis, "simple", test_namespace) + delete_items_from_yaml(kube_apis, f"{TEST_DATA}/auth-basic-auth-mergeable/mergeable/auth-basic-auth-ingress.yaml", + test_namespace) + + request.addfinalizer(fin) + + return AuthBasicAuthMergeableSetup(ingress_controller_endpoint, ingress_host, master_secret_name, minion_secret_name, credentials_dict) + + +def get_credentials_from_file(creds_type) -> str: + """ + Get credentials from the file. + + :param creds_type: 'master' or 'minion' + :return: str + """ + with open(f"{TEST_DATA}/auth-basic-auth-mergeable/credentials/auth-basic-auth-{creds_type}-credentials.txt", "r") as credentials_file: + return credentials_file.read().replace('\n', '') + + +step_1_expected_results = [{"creds_type": "master", "path": "", "response_code": 404}, + {"creds_type": "master", "path": "backend1", "response_code": 401}, + {"creds_type": "master", "path": "backend2", "response_code": 200}, + {"creds_type": "minion", "path": "", "response_code": 401}, + {"creds_type": "minion", "path": "backend1", "response_code": 200}, + {"creds_type": "minion", "path": "backend2", "response_code": 401}] + +step_2_expected_results = [{"creds_type": "master", "path": "", "response_code": 401}, + {"creds_type": "master", "path": "backend1", "response_code": 401}, + {"creds_type": "master", "path": "backend2", "response_code": 401}, + {"creds_type": "minion", "path": "", "response_code": 404}, + {"creds_type": "minion", "path": "backend1", "response_code": 200}, + {"creds_type": "minion", "path": "backend2", "response_code": 200}] + +step_3_expected_results = [{"creds_type": "master", "path": "", "response_code": 401}, + {"creds_type": "master", "path": "backend1", "response_code": 200}, + {"creds_type": "master", "path": "backend2", "response_code": 401}, + {"creds_type": "minion", "path": "", "response_code": 404}, + {"creds_type": "minion", "path": "backend1", "response_code": 401}, + {"creds_type": "minion", "path": "backend2", "response_code": 200}] + +step_4_expected_results = [{"creds_type": "master", "path": "", "response_code": 401}, + {"creds_type": "master", "path": "backend1", "response_code": 403}, + {"creds_type": "master", "path": "backend2", "response_code": 401}, + {"creds_type": "minion", "path": "", "response_code": 404}, + {"creds_type": "minion", "path": "backend1", "response_code": 403}, + {"creds_type": "minion", "path": "backend2", "response_code": 200}] + +step_5_expected_results = [{"creds_type": "master", "path": "", "response_code": 403}, + {"creds_type": "master", "path": "backend1", "response_code": 403}, + {"creds_type": "master", "path": "backend2", "response_code": 403}, + {"creds_type": "minion", "path": "", "response_code": 403}, + {"creds_type": "minion", "path": "backend1", "response_code": 403}, + {"creds_type": "minion", "path": "backend2", "response_code": 403}] + + +@pytest.mark.ingresses +class TestAuthBasicAuthMergeableMinions: + def test_auth_basic_auth_response_codes(self, kube_apis, auth_basic_auth_setup, test_namespace): + print("Step 1: execute check after secrets creation") + execute_checks(auth_basic_auth_setup, step_1_expected_results) + + print("Step 2: replace master secret") + replace_secret(kube_apis.v1, auth_basic_auth_setup.master_secret_name, test_namespace, + f"{TEST_DATA}/auth-basic-auth-mergeable/auth-basic-master-secret-updated.yaml") + wait_before_test(1) + execute_checks(auth_basic_auth_setup, step_2_expected_results) + + print("Step 3: now replace minion secret as well") + replace_secret(kube_apis.v1, auth_basic_auth_setup.minion_secret_name, test_namespace, + f"{TEST_DATA}/auth-basic-auth-mergeable/auth-basic-minion-secret-updated.yaml") + wait_before_test(1) + execute_checks(auth_basic_auth_setup, step_3_expected_results) + + print("Step 4: now remove minion secret") + delete_secret(kube_apis.v1, auth_basic_auth_setup.minion_secret_name, test_namespace) + wait_before_test(1) + execute_checks(auth_basic_auth_setup, step_4_expected_results) + + print("Step 5: finally remove master secret as well") + delete_secret(kube_apis.v1, auth_basic_auth_setup.master_secret_name, test_namespace) + wait_before_test(1) + execute_checks(auth_basic_auth_setup, step_5_expected_results) + + +def execute_checks(auth_basic_auth_setup, expected_results) -> None: + """ + Assert response code. + + :param auth_basic_auth_setup: AuthBasicAuthMergeableSetup + :param expected_results: an array of expected results + :return: + """ + for expected in expected_results: + req_url = f"http://{auth_basic_auth_setup.public_endpoint.public_ip}:{auth_basic_auth_setup.public_endpoint.port}/{expected['path']}" + resp = requests.get(req_url, headers={ + "host": auth_basic_auth_setup.ingress_host, + "authorization": f"Basic {to_base64(auth_basic_auth_setup.credentials_dict[expected['creds_type']])}", + }, + allow_redirects=False) + assert resp.status_code == expected['response_code'] diff --git a/tests/suite/test_auth_basic_secrets.py b/tests/suite/test_auth_basic_secrets.py new file mode 100644 index 0000000000..42bd683cb1 --- /dev/null +++ b/tests/suite/test_auth_basic_secrets.py @@ -0,0 +1,115 @@ +import requests +import pytest + +from base64 import b64encode +from suite.fixtures import PublicEndpoint +from suite.resources_utils import create_secret_from_yaml, delete_secret, replace_secret, ensure_connection_to_public_endpoint, wait_before_test +from suite.resources_utils import create_items_from_yaml, delete_items_from_yaml, create_example_app, delete_common_app +from suite.resources_utils import wait_until_all_pods_are_ready, is_secret_present +from suite.yaml_utils import get_first_ingress_host_from_yaml +from settings import TEST_DATA + +def to_base64(b64_string): + return b64encode( + b64_string.encode('ascii') + ).decode('ascii') + + +class AuthBasicSecretsSetup: + """ + Encapsulate Auth Basic Secrets Example details. + + Attributes: + public_endpoint (PublicEndpoint): + ingress_host (str): + credentials (str): + """ + def __init__(self, public_endpoint: PublicEndpoint, ingress_host, credentials): + self.public_endpoint = public_endpoint + self.ingress_host = ingress_host + self.credentials = credentials + + +class AuthBasicSecret: + """ + Encapsulate secret name for Auth Basic Secrets Example. + + Attributes: + secret_name (str): + """ + def __init__(self, secret_name): + self.secret_name = secret_name + + +@pytest.fixture(scope="class", params=["standard", "mergeable"]) +def auth_basic_secrets_setup(request, kube_apis, ingress_controller_endpoint, ingress_controller, test_namespace) -> AuthBasicSecretsSetup: + with open(f"{TEST_DATA}/auth-basic-secrets/credentials/credentials.txt", "r") as credentials_file: + credentials = credentials_file.read().replace('\n', '') + print("------------------------- Deploy Auth Basic Secrets Example -----------------------------------") + create_items_from_yaml(kube_apis, f"{TEST_DATA}/auth-basic-secrets/{request.param}/auth-basic-secrets-ingress.yaml", test_namespace) + ingress_host = get_first_ingress_host_from_yaml(f"{TEST_DATA}/auth-basic-secrets/{request.param}/auth-basic-secrets-ingress.yaml") + create_example_app(kube_apis, "simple", test_namespace) + wait_until_all_pods_are_ready(kube_apis.v1, test_namespace) + ensure_connection_to_public_endpoint(ingress_controller_endpoint.public_ip, + ingress_controller_endpoint.port, + ingress_controller_endpoint.port_ssl) + + def fin(): + print("Clean up the Auth Basic Secrets Application:") + delete_common_app(kube_apis, "simple", test_namespace) + delete_items_from_yaml(kube_apis, f"{TEST_DATA}/auth-basic-secrets/{request.param}/auth-basic-secrets-ingress.yaml", + test_namespace) + + request.addfinalizer(fin) + + return AuthBasicSecretsSetup(ingress_controller_endpoint, ingress_host, credentials) + + +@pytest.fixture +def auth_basic_secret(request, kube_apis, ingress_controller_endpoint, auth_basic_secrets_setup, test_namespace) -> AuthBasicSecret: + secret_name = create_secret_from_yaml(kube_apis.v1, test_namespace, f"{TEST_DATA}/auth-basic-secrets/auth-basic-secret.yaml") + wait_before_test(1) + + def fin(): + print("Delete Secret:") + if is_secret_present(kube_apis.v1, secret_name, test_namespace): + delete_secret(kube_apis.v1, secret_name, test_namespace) + + request.addfinalizer(fin) + + return AuthBasicSecret(secret_name) + + +@pytest.mark.ingresses +class TestAuthBasicSecrets: + def test_response_code_200_and_server_name(self, auth_basic_secrets_setup, auth_basic_secret): + req_url = f"http://{auth_basic_secrets_setup.public_endpoint.public_ip}:{auth_basic_secrets_setup.public_endpoint.port}/backend2" + resp = requests.get(req_url, headers={"host": auth_basic_secrets_setup.ingress_host, "authorization": f"Basic {to_base64(auth_basic_secrets_setup.credentials)}"}) + assert resp.status_code == 200 + assert f"Server name: backend2" in resp.text + + def test_response_codes_after_secret_remove_and_restore(self, kube_apis, auth_basic_secrets_setup, test_namespace, auth_basic_secret): + req_url = f"http://{auth_basic_secrets_setup.public_endpoint.public_ip}:{auth_basic_secrets_setup.public_endpoint.port}/backend2" + delete_secret(kube_apis.v1, auth_basic_secret.secret_name, test_namespace) + wait_before_test(1) + resp = requests.get(req_url, headers={"host": auth_basic_secrets_setup.ingress_host, "authorization": f"Basic {to_base64(auth_basic_secrets_setup.credentials)}"}) + assert resp.status_code == 403 + + auth_basic_secret.secret_name = create_secret_from_yaml(kube_apis.v1, test_namespace, f"{TEST_DATA}/auth-basic-secrets/auth-basic-secret.yaml") + wait_before_test(1) + resp = requests.get(req_url, headers={"host": auth_basic_secrets_setup.ingress_host, "authorization": f"Basic {to_base64(auth_basic_secrets_setup.credentials)}"}) + assert resp.status_code == 200 + + def test_response_code_403_with_invalid_secret(self, kube_apis, auth_basic_secrets_setup, test_namespace, auth_basic_secret): + req_url = f"http://{auth_basic_secrets_setup.public_endpoint.public_ip}:{auth_basic_secrets_setup.public_endpoint.port}/backend2" + replace_secret(kube_apis.v1, auth_basic_secret.secret_name, test_namespace, f"{TEST_DATA}/auth-basic-secrets/auth-basic-secret-invalid.yaml") + wait_before_test(1) + resp = requests.get(req_url, headers={"host": auth_basic_secrets_setup.ingress_host, "authorization": f"Basic {to_base64(auth_basic_secrets_setup.credentials)}"}) + assert resp.status_code == 403 + + def test_response_code_401_with_updated_secret(self, kube_apis, auth_basic_secrets_setup, test_namespace, auth_basic_secret): + req_url = f"http://{auth_basic_secrets_setup.public_endpoint.public_ip}:{auth_basic_secrets_setup.public_endpoint.port}/backend2" + replace_secret(kube_apis.v1, auth_basic_secret.secret_name, test_namespace, f"{TEST_DATA}/auth-basic-secrets/auth-basic-secret-updated.yaml") + wait_before_test(1) + resp = requests.get(req_url, headers={"host": auth_basic_secrets_setup.ingress_host, "authorization": f"Basic {to_base64(auth_basic_secrets_setup.credentials)}"}, allow_redirects=False) + assert resp.status_code == 401 From c7c1723809dc0e3bfff54d64236eb1fe5c047ab6 Mon Sep 17 00:00:00 2001 From: svvac <_@svvac.net> Date: Tue, 3 May 2022 17:07:32 +0200 Subject: [PATCH 5/6] add documentation for HTTP Basic authentication policy support --- docs/content/configuration/policy-resource.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/content/configuration/policy-resource.md b/docs/content/configuration/policy-resource.md index 7e80154173..e691e3dca8 100644 --- a/docs/content/configuration/policy-resource.md +++ b/docs/content/configuration/policy-resource.md @@ -39,6 +39,7 @@ spec: |``accessControl`` | The access control policy based on the client IP address. | [accessControl](#accesscontrol) | No | |``ingressClassName`` | Specifies which Ingress Controller must handle the Policy resource. | ``string`` | No | |``rateLimit`` | The rate limit policy controls the rate of processing requests per a defined key. | [rateLimit](#ratelimit) | No | +|``basicAuth`` | The basic auth policy configures NGINX to authenticate client requests using HTTP Basic authentication credentials. | [basicAuth](#basic-auth) | No | |``jwt`` | The JWT policy configures NGINX Plus to authenticate client requests using JSON Web Tokens. | [jwt](#jwt) | No | |``ingressMTLS`` | The IngressMTLS policy configures client certificate verification. | [ingressMTLS](#ingressmtls) | No | |``egressMTLS`` | The EgressMTLS policy configures upstreams authentication and certificate verification. | [egressMTLS](#egressmtls) | No | @@ -132,6 +133,36 @@ policies: When you reference more than one rate limit policy, the Ingress Controller will configure NGINX to use all referenced rate limits. When you define multiple policies, each additional policy inherits the `dryRun`, `logLevel`, and `rejectCode` parameters from the first policy referenced (`rate-limit-policy-one`, in the example above). +### BasicAuth + +The basic auth policy configures NGINX to authenticate cllient requests using the [HTTP Basic authentication scheme](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). + +For example, the following policy will reject all requests that do not include a valid username/password combination in the HTTP header `Authentication` +```yaml +basicAuth: + secret: htpasswd-secret + realm: "My API" +``` + +> Note: The feature is implemented using the NGINX [ngx_http_auth_basic_module](https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html). + +{{% table %}} +|Field | Description | Type | Required | +| ---| ---| ---| --- | +|``secret`` | The name of the Kubernetes secret that stores the Htpasswd configuration. It must be in the same namespace as the Policy resource. The secret must be of the type ``nginx.org/htpasswd``, and the config must be stored in the secret under the key ``htpasswd``, otherwise the secret will be rejected as invalid. | ``string`` | Yes | +|``realm`` | The realm for the basic authentication. | ``string`` | No | +{{% /table %}} + +#### BasicAuth Merging Behavior + +A VirtualServer/VirtualServerRoute can reference multiple basic auth policies. However, only one can be applied. Every subsequent reference will be ignored. For example, here we reference two policies: +```yaml +policies: +- name: basic-auth-policy-one +- name: basic-auth-policy-two +``` +In this example the Ingress Controller will use the configuration from the first policy reference `basic-auth-policy-one`, and ignores `basic-auth-policy-two`. + ### JWT > Note: This feature is only available in NGINX Plus. From 6416a37a911469ac2d31e809ad2a126ce3c5a917 Mon Sep 17 00:00:00 2001 From: svvac <_@svvac.net> Date: Tue, 3 May 2022 17:07:58 +0200 Subject: [PATCH 6/6] add documentation fo HTTP Basic authentication ingress annotations support --- ...advanced-configuration-with-annotations.md | 4 + examples/basic-auth/README.md | 116 ++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 examples/basic-auth/README.md diff --git a/docs/content/configuration/ingress-resources/advanced-configuration-with-annotations.md b/docs/content/configuration/ingress-resources/advanced-configuration-with-annotations.md index c9d2e52bbf..372cc62e29 100644 --- a/docs/content/configuration/ingress-resources/advanced-configuration-with-annotations.md +++ b/docs/content/configuration/ingress-resources/advanced-configuration-with-annotations.md @@ -83,6 +83,8 @@ Note how the events section includes a Warning event with the Rejected reason. The following Ingress annotations currently have limited or no validation: +- `nginx.org/basic-auth-secret`, +- `nginx.org/basic-auth-realm`, - `nginx.com/jwt-key`, - `nginx.com/jwt-realm`, - `nginx.com/jwt-token`, @@ -143,6 +145,8 @@ The table below summarizes the available annotations. |``nginx.org/hsts-max-age`` | ``hsts-max-age`` | Sets the value of the ``max-age`` directive of the HSTS header. | ``2592000`` (1 month) | | |``nginx.org/hsts-include-subdomains`` | ``hsts-include-subdomains`` | Adds the ``includeSubDomains`` directive to the HSTS header. | ``False`` | | |``nginx.org/hsts-behind-proxy`` | ``hsts-behind-proxy`` | Enables HSTS based on the value of the ``http_x_forwarded_proto`` request header. Should only be used when TLS termination is configured in a load balancer (proxy) in front of the Ingress Controller. Note: to control redirection from HTTP to HTTPS configure the ``nginx.org/redirect-to-https`` annotation. | ``False`` | | +|``nginx.org/basic-auth-secret`` | N/A | Specifies a Secret resource with a user list for HTTP Basic authentication. | N/A | | +|``nginx.org/basic-auth-realm`` | N/A | Specifies a realm. | N/A | | |``nginx.com/jwt-key`` | N/A | Specifies a Secret resource with keys for validating JSON Web Tokens (JWTs). | N/A | [Support for JSON Web Tokens (JWTs)](https://github.com/nginxinc/kubernetes-ingress/tree/v2.2.2/examples/jwt). | |``nginx.com/jwt-realm`` | N/A | Specifies a realm. | N/A | [Support for JSON Web Tokens (JWTs)](https://github.com/nginxinc/kubernetes-ingress/tree/v2.2.2/examples/jwt). | |``nginx.com/jwt-token`` | N/A | Specifies a variable that contains a JSON Web Token. | By default, a JWT is expected in the ``Authorization`` header as a Bearer Token. | [Support for JSON Web Tokens (JWTs)](https://github.com/nginxinc/kubernetes-ingress/tree/v2.2.2/examples/jwt). | diff --git a/examples/basic-auth/README.md b/examples/basic-auth/README.md new file mode 100644 index 0000000000..3b954f2934 --- /dev/null +++ b/examples/basic-auth/README.md @@ -0,0 +1,116 @@ +# Support for HTTP Basic Authentication + +NGINX supports authenticating requests with [ngx_http_auth_basic_module](https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html). + +The Ingress controller provides the following 2 annotations for configuring Basic Auth validation: + +* Required: ```nginx.org/basic-auth-secret: "secret"``` -- specifies a Secret resource with a htpasswd user list. The htpasswd must be stored in the `htpasswd` data field. The type of the secret must be `nginx.org/htpasswd`. +* Optional: ```nginx.org/basic-auth-realm: "realm"``` -- specifies a realm. + +``` + +## Example 1: The Same Htpasswd for All Paths + +In the following example we enable HTTP Basic authentication for the cafe-ingress Ingress for all paths using the same htpasswd `cafe-htpasswd`: +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: cafe-ingress + annotations: + nginx.org/basic-auth-secret: "cafe-passwd" + nginx.org/basic-auth-realm: "Cafe App" +spec: + tls: + - hosts: + - cafe.example.com + secretName: cafe-secret + rules: + - host: cafe.example.com + http: + paths: + - path: /tea + backend: + service: + name: tea-svc + port: + number: 80 + - path: /coffee + backend: + service: + name: coffee-svc + port: + number: 80 +``` +* The keys must be deployed separately in the Secret `cafe-jwk`. +* The realm is `Cafe App`. + +## Example 2: a Separate Htpasswd Per Path + +In the following example we enable Basic Auth validation for the [mergeable Ingresses](../mergeable-ingress-types) with a separate Basic Auth user:password list per path: + +* Master: + ```yaml + apiVersion: networking.k8s.io/v1 + kind: Ingress + metadata: + name: cafe-ingress-master + annotations: + kubernetes.io/ingress.class: "nginx" + nginx.org/mergeable-ingress-type: "master" + spec: + tls: + - hosts: + - cafe.example.com + secretName: cafe-secret + rules: + - host: cafe.example.com + ``` + +* Tea minion: + ```yaml + apiVersion: networking.k8s.io/v1 + kind: Ingress + metadata: + name: cafe-ingress-tea-minion + annotations: + nginx.org/mergeable-ingress-type: "minion" + nginx.org/basic-auth-secret: "tea-passwd" + nginx.org/basic-auth-realm: "Tea" + spec: + rules: + - host: cafe.example.com + http: + paths: + - path: /tea + pathType: Prefix + backend: + service: + name: tea-svc + port: + number: 80 + ``` + +* Coffee minion: + ```yaml + apiVersion: networking.k8s.io/v1 + kind: Ingress + metadata: + name: cafe-ingress-coffee-minion + annotations: + nginx.org/mergeable-ingress-type: "minion" + nginx.org/basic-auth-secret: "coffee-passwd" + nginx.org/basic-auth-realm: "Coffee" + spec: + rules: + - host: cafe.example.com + http: + paths: + - path: /coffee + pathType: Prefix + backend: + service: + name: coffee-svc + port: + number: 80 + ```