From 994cb99196b5e58879e30237e20451ecd7589aeb Mon Sep 17 00:00:00 2001 From: Dean Coakley Date: Mon, 7 Sep 2020 18:17:06 +0100 Subject: [PATCH 1/3] Add JWT policy support --- deployments/common/policy-definition.yaml | 10 + deployments/helm-chart/crds/policy.yaml | 10 + docs-web/configuration/policy-resource.md | 49 +++ examples-of-custom-resources/jwt/README.md | 69 ++++ .../jwt/jwk-secret.yaml | 6 + examples-of-custom-resources/jwt/jwt.yaml | 9 + examples-of-custom-resources/jwt/token.jwt | 1 + .../jwt/virtual-server.yaml | 16 + examples-of-custom-resources/jwt/webapp.yaml | 32 ++ internal/configs/configurator.go | 40 ++- internal/configs/version2/http.go | 11 +- .../version2/nginx-plus.virtualserver.tmpl | 10 + internal/configs/version2/templates_test.go | 4 + internal/configs/virtualserver.go | 45 ++- internal/configs/virtualserver_test.go | 138 +++++++- internal/k8s/controller.go | 141 ++++++++- internal/k8s/controller_test.go | 298 +++++++++++++++++- pkg/apis/configuration/v1alpha1/types.go | 8 + .../v1alpha1/zz_generated.deepcopy.go | 21 ++ pkg/apis/configuration/validation/common.go | 16 + pkg/apis/configuration/validation/policy.go | 91 +++++- .../configuration/validation/policy_test.go | 161 +++++++++- .../configuration/validation/virtualserver.go | 16 - 23 files changed, 1147 insertions(+), 55 deletions(-) create mode 100644 examples-of-custom-resources/jwt/README.md create mode 100644 examples-of-custom-resources/jwt/jwk-secret.yaml create mode 100644 examples-of-custom-resources/jwt/jwt.yaml create mode 100644 examples-of-custom-resources/jwt/token.jwt create mode 100644 examples-of-custom-resources/jwt/virtual-server.yaml create mode 100644 examples-of-custom-resources/jwt/webapp.yaml diff --git a/deployments/common/policy-definition.yaml b/deployments/common/policy-definition.yaml index 85d63ce0ab..73237addcd 100644 --- a/deployments/common/policy-definition.yaml +++ b/deployments/common/policy-definition.yaml @@ -53,6 +53,16 @@ spec: type: array items: type: string + jwt: + description: JWTAuth holds JWT authentication configuration. + type: object + properties: + realm: + type: string + secret: + type: string + token: + type: string rateLimit: description: RateLimit defines a rate limit policy. type: object diff --git a/deployments/helm-chart/crds/policy.yaml b/deployments/helm-chart/crds/policy.yaml index f5fda77842..2e536e1566 100644 --- a/deployments/helm-chart/crds/policy.yaml +++ b/deployments/helm-chart/crds/policy.yaml @@ -55,6 +55,16 @@ spec: type: array items: type: string + jwt: + description: JWTAuth holds JWT authentication configuration. + type: object + properties: + realm: + type: string + secret: + type: string + token: + type: string rateLimit: description: RateLimit defines a rate limit policy. type: object diff --git a/docs-web/configuration/policy-resource.md b/docs-web/configuration/policy-resource.md index 30114e48d1..b3f4e0e99e 100644 --- a/docs-web/configuration/policy-resource.md +++ b/docs-web/configuration/policy-resource.md @@ -18,6 +18,8 @@ This document is the reference documentation for the Policy resource. An example - [AccessControl Merging Behavior](#accesscontrol-merging-behavior) - [RateLimit](#ratelimit) - [RateLimit Merging Behavior](#ratelimit-merging-behavior) + - [JWT](#jwt) + - [JWT Merging Behavior](#jwt-merging-behavior) - [Using Policy](#using-policy) - [Validation](#validation) - [Structural Validation](#structural-validation) @@ -189,6 +191,53 @@ 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). +### JWT + +> Note: The feature is only available in NGINX Plus. + +The JWT policy configures NGINX Plus to authenticate client requests using JSON Web Tokens. + +For example, the following policy will reject all requests that do not include a valid JWT in the HTTP header `token`: +```yaml +jwt: + secret: jwk-secret + realm: "My API" +``` + +> Note: The feature is implemented using the NGINX Plus [ngx_http_auth_jwt_module](https://nginx.org/en/docs/http/ngx_http_auth_jwt_module.html). + +```eval_rst +.. list-table:: + :header-rows: 1 + + * - Field + - Description + - Type + - Required + * - ``secret`` + - The name of the Kubernetes secret that stores the JWK. It must be in the same namespace as the Policy resource. The JWK must be stored in the secret under the key ``jwk``, otherwise the secret will be rejected as invalid. + - ``string`` + - Yes + * - ``realm`` + - The realm of the JWT. + - ``string`` + - Yes + * - ``token`` + - The token specifies a variable that contains the JSON Web Token. By default the JWT is passed in the ``Authorization`` header as a Bearer Token. JWT may be also passed as a cookie or a part of a query string, for example: ``$cookie_auth_token``. Accepted variables are ``$http_``, ``$arg_``, ``$cookie_``. + - ``string`` + - No +``` + +#### JWT Merging Behavior + +A VirtualServer/VirtualServerRoute can reference multiple JWT policies. However, only one can be applied. Every subsequent reference will be ignored. For example, here we reference two policies: +```yaml +policies: +- name: jwt-policy-one +- name: jwt-policy-two +``` +In this example the Ingress Controller will use the configuration from the first policy reference `jwt-policy-one`, and ignores `jwt-policy-two`. + ## Using Policy You can use the usual `kubectl` commands to work with Policy resources, just as with built-in Kubernetes resources. diff --git a/examples-of-custom-resources/jwt/README.md b/examples-of-custom-resources/jwt/README.md new file mode 100644 index 0000000000..dd3049e24e --- /dev/null +++ b/examples-of-custom-resources/jwt/README.md @@ -0,0 +1,69 @@ +# JWT + +In this example, we deploy a web application, configure load balancing for it via a VirtualServer, and apply a JWT policy. + +## Prerequisites + +1. Follow the [installation](https://docs.nginx.com/nginx-ingress-controller/installation/installation-with-manifests/) instructions to deploy the Ingress Controller. +1. Save the public IP address of the Ingress Controller into a shell variable: + ``` + $ IC_IP=XXX.YYY.ZZZ.III + ``` +1. Save the HTTP port of the Ingress Controller into a shell variable: + ``` + $ IC_HTTP_PORT= + ``` + +## Step 1 - Deploy a Web Application + +Create the application deployment and service: +``` +$ kubectl apply -f webapp.yaml +``` + +## Step 2 - Deploy the JWK Secret + +Create a secret with the name `jwk-secret` that will be used for JWT validation: +``` +$ kubectl apply -f jwk-secret.yaml +``` + +## Step 3 - Deploy the JWT Policy + +Create a policy with the name `jwt-policy` that references the secret from the previous step and only permits requests to our web application with a valid JWT provided: +``` +$ kubectl apply -f jwt.yaml +``` + +## Step 3 - Configure Load Balancing + +Create a VirtualServer resource for the web application: +``` +$ kubectl apply -f virtual-server.yaml +``` + +Note that the VirtualServer references the policy `jwt-policy` created in Step 3. + +## Step 4 - Test the Configuration + +If you attempt to access the application without providing a valid JWT, NGINX will reject your requests for that VirtualServer: +``` +$ curl --resolve webapp.example.com:$IC_HTTP_PORT:$IC_IP http://webapp.example.com:$IC_HTTP_PORT/ + +401 Authorization Required + +

401 Authorization Required

+
nginx/1.19.1
+ + +``` + +If you provide a valid JWT, your request will succeed: +``` +$ curl --resolve webapp.example.com:$IC_HTTP_PORT:$IC_IP http://webapp.example.com:$IC_HTTP_PORT/ -H "token: `cat token.jwt`" +Server address: 172.17.0.3:8080 +Server name: webapp-7c6d448df9-lcrx6 +Date: 10/Sep/2020:18:20:03 +0000 +URI: / +Request ID: db2c07ce640755ccbe9f666d16f85620 +``` diff --git a/examples-of-custom-resources/jwt/jwk-secret.yaml b/examples-of-custom-resources/jwt/jwk-secret.yaml new file mode 100644 index 0000000000..2a9ea9c191 --- /dev/null +++ b/examples-of-custom-resources/jwt/jwk-secret.yaml @@ -0,0 +1,6 @@ +kind: Secret +metadata: + name: jwk-secret +apiVersion: v1 +data: + jwk: eyJrZXlzIjoKICAgIFt7CiAgICAgICAgImsiOiJabUZ1ZEdGemRHbGphbmQwIiwKICAgICAgICAia3R5Ijoib2N0IiwKICAgICAgICAia2lkIjoiMDAwMSIKICAgIH1dCn0K diff --git a/examples-of-custom-resources/jwt/jwt.yaml b/examples-of-custom-resources/jwt/jwt.yaml new file mode 100644 index 0000000000..659cb7395a --- /dev/null +++ b/examples-of-custom-resources/jwt/jwt.yaml @@ -0,0 +1,9 @@ +apiVersion: k8s.nginx.org/v1alpha1 +kind: Policy +metadata: + name: jwt-policy +spec: + jwt: + realm: MyProductAPI + secret: jwk-secret + token: $http_token diff --git a/examples-of-custom-resources/jwt/token.jwt b/examples-of-custom-resources/jwt/token.jwt new file mode 100644 index 0000000000..eacb21aa8d --- /dev/null +++ b/examples-of-custom-resources/jwt/token.jwt @@ -0,0 +1 @@ +eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6IjAwMDEifQ.eyJuYW1lIjoiUXVvdGF0aW9uIFN5c3RlbSIsInN1YiI6InF1b3RlcyIsImlzcyI6Ik15IEFQSSBHYXRld2F5In0.ggVOHYnVFB8GVPE-VOIo3jD71gTkLffAY0hQOGXPL2I diff --git a/examples-of-custom-resources/jwt/virtual-server.yaml b/examples-of-custom-resources/jwt/virtual-server.yaml new file mode 100644 index 0000000000..4631e4f844 --- /dev/null +++ b/examples-of-custom-resources/jwt/virtual-server.yaml @@ -0,0 +1,16 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: webapp +spec: + host: webapp.example.com + policies: + - name: jwt-policy + upstreams: + - name: webapp + service: webapp-svc + port: 80 + routes: + - path: / + action: + pass: webapp diff --git a/examples-of-custom-resources/jwt/webapp.yaml b/examples-of-custom-resources/jwt/webapp.yaml new file mode 100644 index 0000000000..31fde92a6e --- /dev/null +++ b/examples-of-custom-resources/jwt/webapp.yaml @@ -0,0 +1,32 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: webapp +spec: + replicas: 1 + selector: + matchLabels: + app: webapp + template: + metadata: + labels: + app: webapp + spec: + containers: + - name: webapp + image: nginxdemos/nginx-hello:plain-text + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: webapp-svc +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: webapp diff --git a/internal/configs/configurator.go b/internal/configs/configurator.go index 9044ef4745..ed00c79259 100644 --- a/internal/configs/configurator.go +++ b/internal/configs/configurator.go @@ -406,8 +406,11 @@ func (cnf *Configurator) addOrUpdateVirtualServer(virtualServerEx *VirtualServer if virtualServerEx.TLSSecret != nil { tlsPemFileName = cnf.addOrUpdateTLSSecret(virtualServerEx.TLSSecret) } + + jwtKeys := cnf.addOrUpdateJWKSecretsForVirtualServer(virtualServerEx.JWTKeys) + vsc := newVirtualServerConfigurator(cnf.cfgParams, cnf.isPlus, cnf.IsResolverConfigured(), cnf.staticCfgParams) - vsCfg, warnings := vsc.GenerateVirtualServerConfig(virtualServerEx, tlsPemFileName) + vsCfg, warnings := vsc.GenerateVirtualServerConfig(virtualServerEx, tlsPemFileName, jwtKeys) name := getFileNameForVirtualServer(virtualServerEx.VirtualServer) content, err := cnf.templateExecutorV2.ExecuteVirtualServerTemplate(&vsCfg) if err != nil { @@ -580,8 +583,41 @@ func (cnf *Configurator) addOrUpdateJWKSecret(secret *api_v1.Secret) string { return cnf.nginxManager.CreateSecret(name, data, nginx.JWKSecretFileMode) } -func (cnf *Configurator) AddOrUpdateJWKSecret(secret *api_v1.Secret) { +// AddOrUpdateJWKSecret adds a JWK secret to the filesystem or updates it if it already exists. +func (cnf *Configurator) AddOrUpdateJWKSecret(secret *api_v1.Secret, virtualServerExes []*VirtualServerEx) error { cnf.addOrUpdateJWKSecret(secret) + + if len(virtualServerExes) > 0 { + for _, vsEx := range virtualServerExes { + // It is safe to ignore warnings here as no new warnings should appear when adding or updating a secret + _, err := cnf.addOrUpdateVirtualServer(vsEx) + if err != nil { + return fmt.Errorf("Error adding or updating VirtualServer %v/%v: %v", vsEx.VirtualServer.Namespace, vsEx.VirtualServer.Name, err) + } + } + + if err := cnf.nginxManager.Reload(nginx.ReloadForOtherUpdate); err != nil { + return fmt.Errorf("Error when reloading NGINX when updating Secret: %v", err) + } + } + return nil +} + +// addOrUpdateJWKSecretsForVirtualServer adds JWK secrets to the filesystem or updates them if they already exist. +// Returns map[jwkKeyName]jwtKeyFilename +func (cnf *Configurator) addOrUpdateJWKSecretsForVirtualServer(jwtKeys map[string]*api_v1.Secret) map[string]string { + if !cnf.isPlus { + return nil + } + + jwkSecrets := make(map[string]string) + + for jwkKeyName, jwkKey := range jwtKeys { + filename := cnf.addOrUpdateJWKSecret(jwkKey) + jwkSecrets[jwkKeyName] = filename + } + + return jwkSecrets } // AddOrUpdateTLSSecret adds or updates a file with the content of the TLS secret. diff --git a/internal/configs/version2/http.go b/internal/configs/version2/http.go index 9f396b9510..4a1c6e438f 100644 --- a/internal/configs/version2/http.go +++ b/internal/configs/version2/http.go @@ -66,6 +66,7 @@ type Server struct { Deny []string LimitReqOptions LimitReqOptions LimitReqs []LimitReq + JWTAuth *JWTAuth PoliciesErrorReturn *Return } @@ -109,9 +110,10 @@ type Location struct { InternalProxyPass string Allow []string Deny []string - PoliciesErrorReturn *Return LimitReqOptions LimitReqOptions LimitReqs []LimitReq + JWTAuth *JWTAuth + PoliciesErrorReturn *Return } // ReturnLocation defines a location for returning a fixed response. @@ -266,3 +268,10 @@ type LimitReqOptions struct { func (rl LimitReqOptions) String() string { return fmt.Sprintf("{DryRun %v, LogLevel %q, RejectCode %q}", rl.DryRun, rl.LogLevel, rl.RejectCode) } + +// JWTAuth holds JWT authentication configuration. +type JWTAuth struct { + Secret string + Realm string + Token string +} diff --git a/internal/configs/version2/nginx-plus.virtualserver.tmpl b/internal/configs/version2/nginx-plus.virtualserver.tmpl index 359e6c7d46..f0afa63224 100644 --- a/internal/configs/version2/nginx-plus.virtualserver.tmpl +++ b/internal/configs/version2/nginx-plus.virtualserver.tmpl @@ -131,6 +131,11 @@ server { {{ if $rl.Delay }} delay={{ $rl.Delay }}{{ end }}{{ if $rl.NoDelay }} nodelay{{ end }}; {{ end }} + {{ with $s.JWTAuth }} + auth_jwt "{{ .Realm }}"{{ if .Token }} token={{ .Token }}{{ end }}; + auth_jwt_key_file {{ .Secret }}; + {{ end }} + {{ range $snippet := $s.Snippets }} {{- $snippet }} {{ end }} @@ -220,6 +225,11 @@ server { {{ if $rl.Delay }} delay={{ $rl.Delay }}{{ end }}{{ if $rl.NoDelay }} nodelay{{ end }}; {{ end }} + {{ with $l.JWTAuth }} + auth_jwt "{{ .Realm }}"{{ if .Token }} token={{ .Token }}{{ end }}; + auth_jwt_key_file {{ .Secret }}; + {{ end }} + {{ range $e := $l.ErrorPages }} error_page {{ $e.Codes }} {{ if ne 0 $e.ResponseCode }}={{ $e.ResponseCode }}{{ end }} "{{ $e.Name }}"; {{ end }} diff --git a/internal/configs/version2/templates_test.go b/internal/configs/version2/templates_test.go index abd84fb977..f9436d5519 100644 --- a/internal/configs/version2/templates_test.go +++ b/internal/configs/version2/templates_test.go @@ -136,6 +136,10 @@ var virtualServerCfg = VirtualServerConfig{ LogLevel: "error", RejectCode: 503, }, + JWTAuth: &JWTAuth{ + Realm: "My Api", + Secret: "jwk-secret", + }, Snippets: []string{"# server snippet"}, InternalRedirectLocations: []InternalRedirectLocation{ { diff --git a/internal/configs/virtualserver.go b/internal/configs/virtualserver.go index d668b818b6..33d706b653 100644 --- a/internal/configs/virtualserver.go +++ b/internal/configs/virtualserver.go @@ -50,6 +50,7 @@ type VirtualServerEx struct { VirtualServer *conf_v1.VirtualServer Endpoints map[string][]string TLSSecret *api_v1.Secret + JWTKeys map[string]*api_v1.Secret VirtualServerRoutes []*conf_v1.VirtualServerRoute ExternalNameSvcs map[string]bool Policies map[string]*conf_v1alpha1.Policy @@ -207,11 +208,11 @@ func (vsc *virtualServerConfigurator) generateEndpointsForUpstream(owner runtime } // GenerateVirtualServerConfig generates a full configuration for a VirtualServer -func (vsc *virtualServerConfigurator) GenerateVirtualServerConfig(vsEx *VirtualServerEx, tlsPemFileName string) (version2.VirtualServerConfig, Warnings) { +func (vsc *virtualServerConfigurator) GenerateVirtualServerConfig(vsEx *VirtualServerEx, tlsPemFileName string, jwtKeys map[string]string) (version2.VirtualServerConfig, Warnings) { vsc.clearWarnings() policiesCfg := vsc.generatePolicies(vsEx.VirtualServer, vsEx.VirtualServer.Namespace, vsEx.VirtualServer.Namespace, - vsEx.VirtualServer.Name, vsEx.VirtualServer.Spec.Policies, vsEx.Policies) + vsEx.VirtualServer.Name, vsEx.VirtualServer.Spec.Policies, vsEx.Policies, jwtKeys) // crUpstreams maps an UpstreamName to its conf_v1.Upstream as they are generated // necessary for generateLocation to know what Upstream each Location references @@ -317,7 +318,7 @@ func (vsc *virtualServerConfigurator) GenerateVirtualServerConfig(vsEx *VirtualS vsLocSnippets := r.LocationSnippets routePoliciesCfg := vsc.generatePolicies(vsEx.VirtualServer, vsEx.VirtualServer.Namespace, vsEx.VirtualServer.Namespace, vsEx.VirtualServer.Name, - r.Policies, vsEx.Policies) + r.Policies, vsEx.Policies, jwtKeys) limitReqZones = append(limitReqZones, routePoliciesCfg.LimitReqZones...) if len(r.Matches) > 0 { @@ -379,11 +380,11 @@ func (vsc *virtualServerConfigurator) GenerateVirtualServerConfig(vsEx *VirtualS } routePoliciesCfg := vsc.generatePolicies(vsr, vsr.Namespace, vsEx.VirtualServer.Namespace, vsEx.VirtualServer.Name, - r.Policies, vsEx.Policies) + r.Policies, vsEx.Policies, jwtKeys) // use the VirtualServer route policies if the route does not define any if len(r.Policies) == 0 { routePoliciesCfg = vsc.generatePolicies(vsEx.VirtualServer, vsEx.VirtualServer.Namespace, vsEx.VirtualServer.Namespace, - vsEx.VirtualServer.Name, vsrPoliciesFromVs[vsrNamespaceName], vsEx.Policies) + vsEx.VirtualServer.Name, vsrPoliciesFromVs[vsrNamespaceName], vsEx.Policies, jwtKeys) } limitReqZones = append(limitReqZones, routePoliciesCfg.LimitReqZones...) @@ -457,6 +458,7 @@ func (vsc *virtualServerConfigurator) GenerateVirtualServerConfig(vsEx *VirtualS Deny: policiesCfg.Deny, LimitReqOptions: policiesCfg.LimitReqOptions, LimitReqs: policiesCfg.LimitReqs, + JWTAuth: policiesCfg.JWTAuth, PoliciesErrorReturn: policiesCfg.ErrorReturn, }, SpiffeCerts: vsc.spiffeCerts, @@ -471,16 +473,18 @@ type policiesCfg struct { LimitReqOptions version2.LimitReqOptions LimitReqZones []version2.LimitReqZone LimitReqs []version2.LimitReq + JWTAuth *version2.JWTAuth ErrorReturn *version2.Return } func (vsc *virtualServerConfigurator) generatePolicies(owner runtime.Object, ownerNamespace string, vsNamespace string, - vsName string, policyRefs []conf_v1.PolicyReference, policies map[string]*conf_v1alpha1.Policy) policiesCfg { + vsName string, policyRefs []conf_v1.PolicyReference, policies map[string]*conf_v1alpha1.Policy, jwtKeys map[string]string) policiesCfg { var policyErrorReturn *version2.Return var allow, deny []string var limitReqOptions version2.LimitReqOptions var limitReqZones []version2.LimitReqZone var limitReqs []version2.LimitReq + var JWTAuth *version2.JWTAuth var policyError bool for _, p := range policyRefs { @@ -496,6 +500,7 @@ func (vsc *virtualServerConfigurator) generatePolicies(owner runtime.Object, own allow = append(allow, pol.Spec.AccessControl.Allow...) deny = append(deny, pol.Spec.AccessControl.Deny...) } + if pol.Spec.RateLimit != nil { rlZoneName := fmt.Sprintf("pol_rl_%v_%v_%v_%v", polNamespace, p.Name, vsNamespace, vsName) limitReqs = append(limitReqs, generateLimitReq(rlZoneName, pol.Spec.RateLimit)) @@ -505,19 +510,39 @@ func (vsc *virtualServerConfigurator) generatePolicies(owner runtime.Object, own } else { curOptions := generateLimitReqOptions(pol.Spec.RateLimit) if curOptions.DryRun != limitReqOptions.DryRun { - vsc.addWarningf(owner, "RateLimit policy %v with limit request option dryRun=%v is overridden to dryRun=%v by the first policy reference in this context", + vsc.addWarningf(owner, "RateLimit policy %q with limit request option dryRun=%v is overridden to dryRun=%v by the first policy reference in this context", key, curOptions.DryRun, limitReqOptions.DryRun) } if curOptions.LogLevel != limitReqOptions.LogLevel { - vsc.addWarningf(owner, "RateLimit policy %v with limit request option logLevel=%v is overridden to logLevel=%v by the first policy reference in this context", + vsc.addWarningf(owner, "RateLimit policy %q with limit request option logLevel=%v is overridden to logLevel=%v by the first policy reference in this context", key, curOptions.LogLevel, limitReqOptions.LogLevel) } if curOptions.RejectCode != limitReqOptions.RejectCode { - vsc.addWarningf(owner, "RateLimit policy %v with limit request option rejectCode=%v is overridden to rejectCode=%v by the first policy reference in this context", + vsc.addWarningf(owner, "RateLimit policy %q with limit request option rejectCode=%v is overridden to rejectCode=%v by the first policy reference in this context", key, curOptions.RejectCode, limitReqOptions.RejectCode) } } } + + if pol.Spec.JWTAuth != nil { + if JWTAuth != nil { + vsc.addWarningf(owner, "Multiple jwt policies in the same context is not valid. JWT policy %q will be ignored", key) + continue + } + + jwtSecretKey := fmt.Sprintf("%v/%v", polNamespace, pol.Spec.JWTAuth.Secret) + if _, existsOnFilesystem := jwtKeys[jwtSecretKey]; !existsOnFilesystem { + vsc.addWarningf(owner, `JWT policy %q references a JWKSecret %q which does not exist`, key, jwtSecretKey) + policyError = true + break + } + + JWTAuth = &version2.JWTAuth{ + Secret: jwtKeys[jwtSecretKey], + Realm: pol.Spec.JWTAuth.Realm, + Token: pol.Spec.JWTAuth.Token, + } + } } else { vsc.addWarningf(owner, "Policy %s is missing or invalid", key) policyError = true @@ -538,6 +563,7 @@ func (vsc *virtualServerConfigurator) generatePolicies(owner runtime.Object, own LimitReqOptions: limitReqOptions, LimitReqZones: limitReqZones, LimitReqs: limitReqs, + JWTAuth: JWTAuth, ErrorReturn: policyErrorReturn, } } @@ -598,6 +624,7 @@ func addPoliciesCfgToLocation(cfg policiesCfg, location *version2.Location) { location.Deny = cfg.Deny location.LimitReqOptions = cfg.LimitReqOptions location.LimitReqs = cfg.LimitReqs + location.JWTAuth = cfg.JWTAuth location.PoliciesErrorReturn = cfg.ErrorReturn } diff --git a/internal/configs/virtualserver_test.go b/internal/configs/virtualserver_test.go index 0761f9197a..3162e95d7e 100644 --- a/internal/configs/virtualserver_test.go +++ b/internal/configs/virtualserver_test.go @@ -597,7 +597,8 @@ func TestGenerateVirtualServerConfig(t *testing.T) { isResolverConfigured := false tlsPemFileName := "" vsc := newVirtualServerConfigurator(&baseCfgParams, isPlus, isResolverConfigured, &StaticConfigParams{TLSPassthrough: true}) - result, warnings := vsc.GenerateVirtualServerConfig(&virtualServerEx, tlsPemFileName) + jwtKeys := make(map[string]string) + result, warnings := vsc.GenerateVirtualServerConfig(&virtualServerEx, tlsPemFileName, jwtKeys) if !reflect.DeepEqual(result, expected) { t.Errorf("GenerateVirtualServerConfig returned \n%+v but expected \n%+v", result, expected) } @@ -701,7 +702,8 @@ func TestGenerateVirtualServerConfigWithSpiffeCerts(t *testing.T) { tlsPemFileName := "" staticConfigParams := &StaticConfigParams{TLSPassthrough: true, NginxServiceMesh: true} vsc := newVirtualServerConfigurator(&baseCfgParams, isPlus, isResolverConfigured, staticConfigParams) - result, warnings := vsc.GenerateVirtualServerConfig(&virtualServerEx, tlsPemFileName) + jwtKeys := make(map[string]string) + result, warnings := vsc.GenerateVirtualServerConfig(&virtualServerEx, tlsPemFileName, jwtKeys) if !reflect.DeepEqual(result, expected) { t.Errorf("GenerateVirtualServerConfig returned \n%+v but expected \n%+v", result, expected) } @@ -969,7 +971,8 @@ func TestGenerateVirtualServerConfigForVirtualServerWithSplits(t *testing.T) { isResolverConfigured := false tlsPemFileName := "" vsc := newVirtualServerConfigurator(&baseCfgParams, isPlus, isResolverConfigured, &StaticConfigParams{}) - result, warnings := vsc.GenerateVirtualServerConfig(&virtualServerEx, tlsPemFileName) + jwtKeys := make(map[string]string) + result, warnings := vsc.GenerateVirtualServerConfig(&virtualServerEx, tlsPemFileName, jwtKeys) if !reflect.DeepEqual(result, expected) { t.Errorf("GenerateVirtualServerConfig returned \n%+v but expected \n%+v", result, expected) } @@ -1270,7 +1273,8 @@ func TestGenerateVirtualServerConfigForVirtualServerWithMatches(t *testing.T) { isResolverConfigured := false tlsPemFileName := "" vsc := newVirtualServerConfigurator(&baseCfgParams, isPlus, isResolverConfigured, &StaticConfigParams{}) - result, warnings := vsc.GenerateVirtualServerConfig(&virtualServerEx, tlsPemFileName) + jwtKeys := make(map[string]string) + result, warnings := vsc.GenerateVirtualServerConfig(&virtualServerEx, tlsPemFileName, jwtKeys) if !reflect.DeepEqual(result, expected) { t.Errorf("GenerateVirtualServerConfig returned \n%+v but expected \n%+v", result, expected) } @@ -1741,7 +1745,8 @@ func TestGenerateVirtualServerConfigForVirtualServerWithReturns(t *testing.T) { isResolverConfigured := false tlsPemFileName := "" vsc := newVirtualServerConfigurator(&baseCfgParams, isPlus, isResolverConfigured, &StaticConfigParams{}) - result, warnings := vsc.GenerateVirtualServerConfig(&virtualServerEx, tlsPemFileName) + jwtKeys := make(map[string]string) + result, warnings := vsc.GenerateVirtualServerConfig(&virtualServerEx, tlsPemFileName, jwtKeys) if !reflect.DeepEqual(result, expected) { t.Errorf("GenerateVirtualServerConfig returned \n%+v but expected \n%+v", result, expected) } @@ -1760,6 +1765,7 @@ func TestGeneratePolicies(t *testing.T) { tests := []struct { policyRefs []conf_v1.PolicyReference policies map[string]*conf_v1alpha1.Policy + jwtKeys map[string]string expected policiesCfg msg string }{ @@ -1779,6 +1785,7 @@ func TestGeneratePolicies(t *testing.T) { }, }, }, + jwtKeys: nil, expected: policiesCfg{ Allow: []string{"127.0.0.1"}, }, @@ -1829,6 +1836,7 @@ func TestGeneratePolicies(t *testing.T) { }, }, }, + jwtKeys: nil, expected: policiesCfg{ Allow: []string{"127.0.0.1", "127.0.0.2"}, }, @@ -1853,6 +1861,7 @@ func TestGeneratePolicies(t *testing.T) { }, }, }, + jwtKeys: nil, expected: policiesCfg{ LimitReqZones: []version2.LimitReqZone{ { @@ -1905,6 +1914,7 @@ func TestGeneratePolicies(t *testing.T) { }, }, }, + jwtKeys: nil, expected: policiesCfg{ LimitReqZones: []version2.LimitReqZone{ { @@ -1935,12 +1945,40 @@ func TestGeneratePolicies(t *testing.T) { }, msg: "multi rate limit reference", }, + { + policyRefs: []conf_v1.PolicyReference{ + { + Name: "jwt-policy", + Namespace: "default", + }, + }, + policies: map[string]*conf_v1alpha1.Policy{ + "default/jwt-policy": { + Spec: conf_v1alpha1.PolicySpec{ + JWTAuth: &conf_v1alpha1.JWTAuth{ + Realm: "My Test API", + Secret: "jwt-secret", + }, + }, + }, + }, + jwtKeys: map[string]string{ + "default/jwt-secret": "/etc/nginx/secrets/default-jwt-secret", + }, + expected: policiesCfg{ + JWTAuth: &version2.JWTAuth{ + Secret: "/etc/nginx/secrets/default-jwt-secret", + Realm: "My Test API", + }, + }, + msg: "jwt reference", + }, } vsc := newVirtualServerConfigurator(&ConfigParams{}, false, false, &StaticConfigParams{}) for _, test := range tests { - result := vsc.generatePolicies(owner, ownerNamespace, vsNamespace, vsName, test.policyRefs, test.policies) + result := vsc.generatePolicies(owner, ownerNamespace, vsNamespace, vsName, test.policyRefs, test.policies, test.jwtKeys) if !reflect.DeepEqual(result, test.expected) { t.Errorf("generatePolicies() returned \n%+v but expected \n%+v for the case of %s", result, test.expected, test.msg) @@ -1963,6 +2001,7 @@ func TestGeneratePoliciesFails(t *testing.T) { tests := []struct { policyRefs []conf_v1.PolicyReference policies map[string]*conf_v1alpha1.Policy + jwtKeys map[string]string expected policiesCfg expectedWarnings Warnings msg string @@ -1975,6 +2014,7 @@ func TestGeneratePoliciesFails(t *testing.T) { }, }, policies: map[string]*conf_v1alpha1.Policy{}, + jwtKeys: nil, expected: policiesCfg{ ErrorReturn: &version2.Return{ Code: 500, @@ -2012,6 +2052,7 @@ func TestGeneratePoliciesFails(t *testing.T) { }, }, }, + jwtKeys: nil, expected: policiesCfg{ Allow: []string{"127.0.0.1"}, Deny: []string{"127.0.0.2"}, @@ -2057,6 +2098,7 @@ func TestGeneratePoliciesFails(t *testing.T) { }, }, }, + jwtKeys: nil, expected: policiesCfg{ LimitReqZones: []version2.LimitReqZone{ { @@ -2087,19 +2129,95 @@ func TestGeneratePoliciesFails(t *testing.T) { }, expectedWarnings: map[runtime.Object][]string{ nil: { - "RateLimit policy default/rateLimit-policy2 with limit request option dryRun=true is overridden to dryRun=false by the first policy reference in this context", - "RateLimit policy default/rateLimit-policy2 with limit request option logLevel=info is overridden to logLevel=error by the first policy reference in this context", - "RateLimit policy default/rateLimit-policy2 with limit request option rejectCode=505 is overridden to rejectCode=503 by the first policy reference in this context", + `RateLimit policy "default/rateLimit-policy2" with limit request option dryRun=true is overridden to dryRun=false by the first policy reference in this context`, + `RateLimit policy "default/rateLimit-policy2" with limit request option logLevel=info is overridden to logLevel=error by the first policy reference in this context`, + `RateLimit policy "default/rateLimit-policy2" with limit request option rejectCode=505 is overridden to rejectCode=503 by the first policy reference in this context`, }, }, msg: "rate limit policy limit request option override", }, + { + policyRefs: []conf_v1.PolicyReference{ + { + Name: "jwt-policy", + Namespace: "default", + }, + }, + policies: map[string]*conf_v1alpha1.Policy{ + "default/jwt-policy": { + Spec: conf_v1alpha1.PolicySpec{ + JWTAuth: &conf_v1alpha1.JWTAuth{ + Realm: "test", + Secret: "jwt-secret", + }, + }, + }, + }, + jwtKeys: nil, + expected: policiesCfg{ + ErrorReturn: &version2.Return{ + Code: 500, + }, + }, + expectedWarnings: map[runtime.Object][]string{ + nil: { + `JWT policy "default/jwt-policy" references a JWKSecret "default/jwt-secret" which does not exist`, + }, + }, + msg: "jwt reference missing secret", + }, + { + policyRefs: []conf_v1.PolicyReference{ + { + Name: "jwt-policy", + Namespace: "default", + }, + { + Name: "jwt-policy2", + Namespace: "default", + }, + }, + policies: map[string]*conf_v1alpha1.Policy{ + "default/jwt-policy": { + Spec: conf_v1alpha1.PolicySpec{ + JWTAuth: &conf_v1alpha1.JWTAuth{ + Realm: "test", + Secret: "jwt-secret", + }, + }, + }, + "default/jwt-policy2": { + Spec: conf_v1alpha1.PolicySpec{ + JWTAuth: &conf_v1alpha1.JWTAuth{ + Realm: "test", + Secret: "jwt-secret2", + }, + }, + }, + }, + jwtKeys: map[string]string{ + "default/jwt-secret": "/etc/nginx/secrets/default-jwt-secret", + "default/jwt-secret2": "", + }, + expected: policiesCfg{ + JWTAuth: &version2.JWTAuth{ + Secret: "/etc/nginx/secrets/default-jwt-secret", + Realm: "test", + }, + }, + expectedWarnings: map[runtime.Object][]string{ + nil: { + `Multiple jwt policies in the same context is not valid. JWT policy "default/jwt-policy2" will be ignored`, + }, + }, + msg: "multi jwt reference", + }, } for _, test := range tests { vsc := newVirtualServerConfigurator(&ConfigParams{}, false, false, &StaticConfigParams{}) - result := vsc.generatePolicies(owner, ownerNamespace, vsNamespace, vsName, test.policyRefs, test.policies) + result := vsc.generatePolicies(owner, ownerNamespace, vsNamespace, vsName, test.policyRefs, test.policies, test.jwtKeys) if !reflect.DeepEqual(result, test.expected) { t.Errorf("generatePolicies() returned \n%+v but expected \n%+v for the case of %s", result, test.expected, test.msg) diff --git a/internal/k8s/controller.go b/internal/k8s/controller.go index 68ac1abc6d..5b8034c1e1 100644 --- a/internal/k8s/controller.go +++ b/internal/k8s/controller.go @@ -869,7 +869,7 @@ func (lbc *LoadBalancerController) syncPolicy(task task) { if polExists { pol := obj.(*conf_v1alpha1.Policy) - err := validation.ValidatePolicy(pol) + err := validation.ValidatePolicy(pol, lbc.isNginxPlus) if err != nil { lbc.recorder.Eventf(pol, api_v1.EventTypeWarning, "Rejected", "Policy %v is invalid and was rejected: %v", key, err) } else { @@ -1511,15 +1511,24 @@ func (lbc *LoadBalancerController) syncSecret(task task) { glog.Warningf("Failed to find Ingress resources for Secret %v: %v", key, err) lbc.syncQueue.RequeueAfter(task, err, 5*time.Second) } + glog.V(2).Infof("Found %v Ingresses with Secret %v", len(ings), key) var virtualServers []*conf_v1.VirtualServer + if lbc.areCustomResourcesEnabled { virtualServers = lbc.getVirtualServersForSecret(namespace, name) + + jwkSecretPols := lbc.getPoliciesForSecret(namespace, name) + for _, pol := range jwkSecretPols { + vsList := lbc.getVirtualServersForPolicy(pol.Namespace, pol.Name) + virtualServers = append(virtualServers, vsList...) + } + + virtualServers = removeDuplicateVirtualServers(virtualServers) + glog.V(2).Infof("Found %v VirtualServers with Secret %v", len(virtualServers), key) } - glog.V(2).Infof("Found %v Ingresses with Secret %v", len(ings), key) - if !secrExists { glog.V(2).Infof("Deleting Secret: %v\n", key) @@ -1544,6 +1553,20 @@ func (lbc *LoadBalancerController) syncSecret(task task) { } } +func removeDuplicateVirtualServers(virtualServers []*conf_v1.VirtualServer) []*conf_v1.VirtualServer { + encountered := make(map[string]bool) + var uniqueVirtualServers []*conf_v1.VirtualServer + for _, vs := range virtualServers { + vsKey := fmt.Sprintf("%v/%v", vs.Namespace, vs.Name) + if !encountered[vsKey] { + encountered[vsKey] = true + uniqueVirtualServers = append(uniqueVirtualServers, vs) + } + } + + return uniqueVirtualServers +} + func (lbc *LoadBalancerController) isSpecialSecret(secretName string) bool { return secretName == lbc.defaultServerSecret || secretName == lbc.wildcardTLSSecret } @@ -1604,7 +1627,18 @@ func (lbc *LoadBalancerController) handleSecretUpdate(secret *api_v1.Secret, ing kind, _ := GetSecretKind(secret) if kind == JWK { - lbc.configurator.AddOrUpdateJWKSecret(secret) + virtualServerExes := lbc.virtualServersToVirtualServerExes(virtualServers) + + err := lbc.configurator.AddOrUpdateJWKSecret(secret, virtualServerExes) + if err != nil { + glog.Errorf("Error when updating Secret %v: %v", secretNsName, err) + lbc.recorder.Eventf(secret, api_v1.EventTypeWarning, "UpdatedWithError", "%v was updated, but not applied: %v", secretNsName, err) + + eventType = api_v1.EventTypeWarning + title = "UpdatedWithError" + message = fmt.Sprintf("Configuration was updated due to updated secret %v, but not applied: %v", secretNsName, err) + state = conf_v1.StateInvalid + } } else { regular, mergeable := lbc.createIngresses(ings) @@ -2282,7 +2316,7 @@ func getIPAddressesFromEndpoints(endpoints []podEndpoint) []string { return endps } -func (lbc *LoadBalancerController) getAndValidateSecret(secretKey string) (*api_v1.Secret, error) { +func (lbc *LoadBalancerController) getSecret(secretKey string) (*api_v1.Secret, error) { secretObject, secretExists, err := lbc.secretLister.GetByKey(secretKey) if err != nil { return nil, fmt.Errorf("error retrieving secret %v", secretKey) @@ -2291,6 +2325,14 @@ func (lbc *LoadBalancerController) getAndValidateSecret(secretKey string) (*api_ return nil, fmt.Errorf("secret %v not found", secretKey) } secret := secretObject.(*api_v1.Secret) + return secret, nil +} + +func (lbc *LoadBalancerController) getAndValidateSecret(secretKey string) (*api_v1.Secret, error) { + secret, err := lbc.getSecret(secretKey) + if err != nil { + return nil, err + } err = ValidateTLSSecret(secret) if err != nil { @@ -2299,6 +2341,19 @@ func (lbc *LoadBalancerController) getAndValidateSecret(secretKey string) (*api_ return secret, nil } +func (lbc *LoadBalancerController) getAndValidateJWTSecret(secretKey string) (*api_v1.Secret, error) { + secret, err := lbc.getSecret(secretKey) + if err != nil { + return nil, err + } + + err = ValidateJWKSecret(secret) + if err != nil { + return nil, fmt.Errorf("error validating secret %v", secretKey) + } + return secret, nil +} + func (lbc *LoadBalancerController) createIngress(ing *networking.Ingress) (*configs.IngressEx, error) { ingEx := &configs.IngressEx{ Ingress: ing, @@ -2540,6 +2595,7 @@ func newVirtualServerRouteErrorFromVSR(virtualServerRoute *conf_v1.VirtualServer func (lbc *LoadBalancerController) createVirtualServer(virtualServer *conf_v1.VirtualServer) (*configs.VirtualServerEx, []virtualServerRouteError) { virtualServerEx := configs.VirtualServerEx{ VirtualServer: virtualServer, + JWTKeys: make(map[string]*api_v1.Secret), } if virtualServer.Spec.TLS != nil && virtualServer.Spec.TLS.Secret != "" { @@ -2557,6 +2613,11 @@ func (lbc *LoadBalancerController) createVirtualServer(virtualServer *conf_v1.Vi glog.Warningf("Error getting policy for VirtualServer %s/%s: %v", virtualServer.Namespace, virtualServer.Name, err) } + err := lbc.addJWTSecrets(policies, virtualServerEx.JWTKeys) + if err != nil { + glog.Warningf("Error getting JWT secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) + } + endpoints := make(map[string][]string) externalNameSvcs := make(map[string]bool) podsByIP := make(map[string]configs.PodInfo) @@ -2605,6 +2666,11 @@ func (lbc *LoadBalancerController) createVirtualServer(virtualServer *conf_v1.Vi } policies = append(policies, vsRoutePolicies...) + err = lbc.addJWTSecrets(vsRoutePolicies, virtualServerEx.JWTKeys) + if err != nil { + glog.Warningf("Error getting JWT secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) + } + if r.Route == "" { continue } @@ -2652,6 +2718,11 @@ func (lbc *LoadBalancerController) createVirtualServer(virtualServer *conf_v1.Vi glog.Warningf("Error getting policy for VirtualServerRoute %s/%s: %v", vsr.Namespace, vsr.Name, err) } policies = append(policies, vsrSubroutePolicies...) + + err = lbc.addJWTSecrets(vsrSubroutePolicies, virtualServerEx.JWTKeys) + if err != nil { + glog.Warningf("Error getting JWT secrets for VirtualServerRoute %v/%v: %v", vsr.Namespace, vsr.Name, err) + } } for _, u := range vsr.Spec.Upstreams { @@ -2707,14 +2778,32 @@ func createPolicyMap(policies []*conf_v1alpha1.Policy) map[string]*conf_v1alpha1 return result } -func (lbc *LoadBalancerController) getPolicies(policies []conf_v1.PolicyReference, defaultNamespace string) ([]*conf_v1alpha1.Policy, []error) { +func (lbc *LoadBalancerController) getAllPolicies() []*conf_v1alpha1.Policy { + var policies []*conf_v1alpha1.Policy + + for _, obj := range lbc.policyLister.List() { + pol := obj.(*conf_v1alpha1.Policy) + + err := validation.ValidatePolicy(pol, lbc.isNginxPlus) + if err != nil { + glog.V(3).Infof("Skipping invalid Policy %s/%s: %v", pol.Namespace, pol.Name, err) + continue + } + + policies = append(policies, pol) + } + + return policies +} + +func (lbc *LoadBalancerController) getPolicies(policies []conf_v1.PolicyReference, ownerNamespace string) ([]*conf_v1alpha1.Policy, []error) { var result []*conf_v1alpha1.Policy var errors []error for _, p := range policies { polNamespace := p.Namespace if polNamespace == "" { - polNamespace = defaultNamespace + polNamespace = ownerNamespace } policyKey := fmt.Sprintf("%s/%s", polNamespace, p.Name) @@ -2732,7 +2821,7 @@ func (lbc *LoadBalancerController) getPolicies(policies []conf_v1.PolicyReferenc policy := policyObj.(*conf_v1alpha1.Policy) - err = validation.ValidatePolicy(policy) + err = validation.ValidatePolicy(policy, lbc.isNginxPlus) if err != nil { errors = append(errors, fmt.Errorf("Policy %s is invalid: %v", policyKey, err)) continue @@ -2744,6 +2833,42 @@ func (lbc *LoadBalancerController) getPolicies(policies []conf_v1.PolicyReferenc return result, errors } +func (lbc *LoadBalancerController) addJWTSecrets(policies []*conf_v1alpha1.Policy, jwtKeys map[string]*api_v1.Secret) error { + for _, pol := range policies { + if pol.Spec.JWTAuth == nil { + continue + } + secretKey := fmt.Sprintf("%v/%v", pol.Namespace, pol.Spec.JWTAuth.Secret) + secret, err := lbc.getAndValidateJWTSecret(secretKey) + if err != nil { + return fmt.Errorf("Error getting or validating the JWT secret %v for the policy %v/%v: %v", secretKey, pol.Namespace, pol.Name, err) + } + jwtKeys[secretKey] = secret + } + + return nil +} + +func (lbc *LoadBalancerController) getPoliciesForSecret(secretNamespace string, secretName string) []*conf_v1alpha1.Policy { + return findPoliciesForSecret(lbc.getAllPolicies(), secretNamespace, secretName) +} + +func findPoliciesForSecret(policies []*conf_v1alpha1.Policy, secretNamespace string, secretName string) []*conf_v1alpha1.Policy { + var res []*conf_v1alpha1.Policy + + for _, pol := range policies { + if pol.Spec.JWTAuth == nil { + continue + } + + if pol.Spec.JWTAuth.Secret == secretName && pol.Namespace == secretNamespace { + res = append(res, pol) + } + } + + return res +} + func (lbc *LoadBalancerController) createTransportServer(transportServer *conf_v1alpha1.TransportServer) *configs.TransportServerEx { endpoints := make(map[string][]string) diff --git a/internal/k8s/controller_test.go b/internal/k8s/controller_test.go index 9aff71452b..b1ceeaac82 100644 --- a/internal/k8s/controller_test.go +++ b/internal/k8s/controller_test.go @@ -16,6 +16,7 @@ import ( "github.com/nginxinc/kubernetes-ingress/internal/metrics/collectors" "github.com/nginxinc/kubernetes-ingress/internal/nginx" conf_v1 "github.com/nginxinc/kubernetes-ingress/pkg/apis/configuration/v1" + "github.com/nginxinc/kubernetes-ingress/pkg/apis/configuration/v1alpha1" conf_v1alpha1 "github.com/nginxinc/kubernetes-ingress/pkg/apis/configuration/v1alpha1" v1 "k8s.io/api/core/v1" networking "k8s.io/api/networking/v1beta1" @@ -2139,6 +2140,7 @@ func TestGetPolicies(t *testing.T) { } lbc := LoadBalancerController{ + isNginxPlus: true, policyLister: &cache.FakeCustomStore{ GetByKeyFunc: func(key string) (item interface{}, exists bool, err error) { switch key { @@ -2176,7 +2178,7 @@ func TestGetPolicies(t *testing.T) { expectedPolicies := []*conf_v1alpha1.Policy{validPolicy} expectedErrors := []error{ - errors.New("Policy default/invalid-policy is invalid: spec: Invalid value: \"\": must specify exactly one of: `accessControl`, `rateLimit`"), + errors.New("Policy default/invalid-policy is invalid: spec: Invalid value: \"\": must specify exactly one of: `accessControl`, `rateLimit`, `jwt`"), errors.New("Policy nginx-ingress/valid-policy doesn't exist"), errors.New("Failed to get policy nginx-ingress/some-policy: GetByKey error"), } @@ -2350,3 +2352,297 @@ func policyMapToString(policies map[string]*conf_v1alpha1.Policy) string { return b.String() } + +func TestRemoveDuplicateVirtualServers(t *testing.T) { + tests := []struct { + virtualServers []*conf_v1.VirtualServer + expected []*conf_v1.VirtualServer + }{ + { + []*conf_v1.VirtualServer{ + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "vs-1", + Namespace: "ns-1", + }, + }, + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "vs-2", + Namespace: "ns-1", + }, + }, + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "vs-2", + Namespace: "ns-1", + }, + }, + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "vs-3", + Namespace: "ns-1", + }, + }, + }, + []*conf_v1.VirtualServer{ + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "vs-1", + Namespace: "ns-1", + }, + }, + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "vs-2", + Namespace: "ns-1", + }, + }, + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "vs-3", + Namespace: "ns-1", + }, + }, + }, + }, + { + []*conf_v1.VirtualServer{ + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "vs-3", + Namespace: "ns-2", + }, + }, + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "vs-3", + Namespace: "ns-1", + }, + }, + }, + []*conf_v1.VirtualServer{ + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "vs-3", + Namespace: "ns-2", + }, + }, + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "vs-3", + Namespace: "ns-1", + }, + }, + }, + }, + } + for _, test := range tests { + result := removeDuplicateVirtualServers(test.virtualServers) + if !reflect.DeepEqual(result, test.expected) { + t.Errorf("removeDuplicateVirtualServers() returned \n%v but expected \n%v", result, test.expected) + } + } +} + +func TestFindPoliciesForSecret(t *testing.T) { + jwtPol1 := &conf_v1alpha1.Policy{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "jwt-policy", + Namespace: "default", + }, + Spec: conf_v1alpha1.PolicySpec{ + JWTAuth: &conf_v1alpha1.JWTAuth{ + Secret: "jwk-secret", + }, + }, + } + + jwtPol2 := &conf_v1alpha1.Policy{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "jwt-policy", + Namespace: "ns-1", + }, + Spec: conf_v1alpha1.PolicySpec{ + JWTAuth: &conf_v1alpha1.JWTAuth{ + Secret: "jwk-secret", + }, + }, + } + + tests := []struct { + policies []*conf_v1alpha1.Policy + secretNamespace string + secretName string + expected []*conf_v1alpha1.Policy + msg string + }{ + { + policies: []*conf_v1alpha1.Policy{jwtPol1}, + secretNamespace: "default", + secretName: "jwk-secret", + expected: []*v1alpha1.Policy{jwtPol1}, + msg: "Find policy in default ns", + }, + { + policies: []*conf_v1alpha1.Policy{jwtPol2}, + secretNamespace: "default", + secretName: "jwk-secret", + expected: nil, + msg: "Ignore policies in other namespaces", + }, + { + policies: []*conf_v1alpha1.Policy{jwtPol1, jwtPol2}, + secretNamespace: "default", + secretName: "jwk-secret", + expected: []*v1alpha1.Policy{jwtPol1}, + msg: "Find policy in default ns, ignore other", + }, + } + for _, test := range tests { + result := findPoliciesForSecret(test.policies, test.secretNamespace, test.secretName) + if !reflect.DeepEqual(result, test.expected) { + t.Errorf("findPoliciesForSecret() returned \n%v but expected \n%v for the case of %s", result, test.expected, test.msg) + } + } +} + +func TestAddJWTSecrets(t *testing.T) { + validSecret := &v1.Secret{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "valid-jwk-secret", + Namespace: "default", + }, + Data: map[string][]byte{"jwk": nil}, + } + + invalidSecret := &v1.Secret{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "invalid-jwk-secret", + Namespace: "default", + }, + Data: nil, + } + + tests := []struct { + policies []*conf_v1alpha1.Policy + expectedJWTKeys map[string]*v1.Secret + wantErr bool + msg string + }{ + { + policies: []*conf_v1alpha1.Policy{ + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "jwt-policy", + Namespace: "default", + }, + Spec: conf_v1alpha1.PolicySpec{ + JWTAuth: &conf_v1alpha1.JWTAuth{ + Secret: "valid-jwk-secret", + Realm: "My API", + }, + }, + }, + }, + expectedJWTKeys: map[string]*v1.Secret{ + "default/valid-jwk-secret": validSecret, + }, + wantErr: false, + msg: "test getting valid secret", + }, + { + policies: []*conf_v1alpha1.Policy{}, + expectedJWTKeys: map[string]*v1.Secret{}, + wantErr: false, + msg: "test getting valid secret with no policy", + }, + { + policies: []*conf_v1alpha1.Policy{ + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "jwt-policy", + Namespace: "default", + }, + Spec: conf_v1alpha1.PolicySpec{ + AccessControl: &conf_v1alpha1.AccessControl{ + Allow: []string{"127.0.0.1"}, + }, + }, + }, + }, + expectedJWTKeys: map[string]*v1.Secret{}, + wantErr: false, + msg: "test getting valid secret with wrong policy", + }, + { + policies: []*conf_v1alpha1.Policy{ + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "jwt-policy", + Namespace: "default", + }, + Spec: conf_v1alpha1.PolicySpec{ + JWTAuth: &conf_v1alpha1.JWTAuth{ + Secret: "non-existing-jwk-secret", + Realm: "My API", + }, + }, + }, + }, + expectedJWTKeys: map[string]*v1.Secret{}, + wantErr: true, + msg: "test getting secret that does not exist", + }, + { + policies: []*conf_v1alpha1.Policy{ + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "jwt-policy", + Namespace: "default", + }, + Spec: conf_v1alpha1.PolicySpec{ + JWTAuth: &conf_v1alpha1.JWTAuth{ + Secret: "invalid-jwk-secret", + Realm: "My API", + }, + }, + }, + }, + expectedJWTKeys: map[string]*v1.Secret{}, + wantErr: true, + msg: "test getting invalid secret", + }, + } + + for _, test := range tests { + lbc := LoadBalancerController{ + secretLister: storeToSecretLister{ + &cache.FakeCustomStore{ + GetByKeyFunc: func(key string) (item interface{}, exists bool, err error) { + switch key { + case "default/valid-jwk-secret": + return validSecret, true, nil + case "default/invalid-jwk-secret": + return invalidSecret, true, errors.New("secret is missing jwk key in data") + default: + return nil, false, errors.New("GetByKey error") + } + }, + }, + }, + } + + jwtKeys := make(map[string]*v1.Secret) + + err := lbc.addJWTSecrets(test.policies, jwtKeys) + if (err != nil) != test.wantErr { + t.Errorf("addJWTSecrets() returned %v, for the case of %v", err, test.msg) + } + + if !reflect.DeepEqual(jwtKeys, test.expectedJWTKeys) { + t.Errorf("addJWTSecrets() returned \n%+v but expected \n%+v", jwtKeys, test.expectedJWTKeys) + } + + } +} diff --git a/pkg/apis/configuration/v1alpha1/types.go b/pkg/apis/configuration/v1alpha1/types.go index 1f04c6d776..9ea531830d 100644 --- a/pkg/apis/configuration/v1alpha1/types.go +++ b/pkg/apis/configuration/v1alpha1/types.go @@ -118,6 +118,7 @@ type Policy struct { type PolicySpec struct { AccessControl *AccessControl `json:"accessControl"` RateLimit *RateLimit `json:"rateLimit"` + JWTAuth *JWTAuth `json:"jwt"` } // AccessControl defines an access policy based on the source IP of a request. @@ -139,6 +140,13 @@ type RateLimit struct { RejectCode *int `json:"rejectCode"` } +// JWTAuth holds JWT authentication configuration. +type JWTAuth struct { + Realm string `json:"realm"` + Secret string `json:"secret"` + Token string `json:"token"` +} + // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // PolicyList is a list of the Policy resources. diff --git a/pkg/apis/configuration/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/configuration/v1alpha1/zz_generated.deepcopy.go index b6a56ea675..39e1b1a72b 100644 --- a/pkg/apis/configuration/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/configuration/v1alpha1/zz_generated.deepcopy.go @@ -131,6 +131,22 @@ func (in *GlobalConfigurationSpec) DeepCopy() *GlobalConfigurationSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *JWTAuth) DeepCopyInto(out *JWTAuth) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JWTAuth. +func (in *JWTAuth) DeepCopy() *JWTAuth { + if in == nil { + return nil + } + out := new(JWTAuth) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Listener) DeepCopyInto(out *Listener) { *out = *in @@ -220,6 +236,11 @@ func (in *PolicySpec) DeepCopyInto(out *PolicySpec) { *out = new(RateLimit) (*in).DeepCopyInto(*out) } + if in.JWTAuth != nil { + in, out := &in.JWTAuth, &out.JWTAuth + *out = new(JWTAuth) + **out = **in + } return } diff --git a/pkg/apis/configuration/validation/common.go b/pkg/apis/configuration/validation/common.go index e38a54a024..a614177785 100644 --- a/pkg/apis/configuration/validation/common.go +++ b/pkg/apis/configuration/validation/common.go @@ -117,6 +117,22 @@ func validateSize(size string, fieldPath *field.Path) field.ErrorList { return allErrs } +// validateSecretName checks if a secret name is valid. +// It performs the same validation as ValidateSecretName from k8s.io/kubernetes/pkg/apis/core/validation/validation.go. +func validateSecretName(name string, fieldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + if name == "" { + return allErrs + } + + for _, msg := range validation.IsDNS1123Subdomain(name) { + allErrs = append(allErrs, field.Invalid(fieldPath, name, msg)) + } + + return allErrs +} + func mapToPrettyString(m map[string]bool) string { var out []string diff --git a/pkg/apis/configuration/validation/policy.go b/pkg/apis/configuration/validation/policy.go index af18ac3185..d55f182de9 100644 --- a/pkg/apis/configuration/validation/policy.go +++ b/pkg/apis/configuration/validation/policy.go @@ -13,12 +13,12 @@ import ( ) // ValidatePolicy validates a Policy. -func ValidatePolicy(policy *v1alpha1.Policy) error { - allErrs := validatePolicySpec(&policy.Spec, field.NewPath("spec")) +func ValidatePolicy(policy *v1alpha1.Policy, isPlus bool) error { + allErrs := validatePolicySpec(&policy.Spec, field.NewPath("spec"), isPlus) return allErrs.ToAggregate() } -func validatePolicySpec(spec *v1alpha1.PolicySpec, fieldPath *field.Path) field.ErrorList { +func validatePolicySpec(spec *v1alpha1.PolicySpec, fieldPath *field.Path, isPlus bool) field.ErrorList { allErrs := field.ErrorList{} fieldCount := 0 @@ -33,8 +33,22 @@ func validatePolicySpec(spec *v1alpha1.PolicySpec, fieldPath *field.Path) field. fieldCount++ } + if spec.JWTAuth != nil { + if !isPlus { + return append(allErrs, field.Forbidden(fieldPath.Child("jwt"), "jwt secrets are only supported in NGINX Plus")) + } + + allErrs = append(allErrs, validateJWT(spec.JWTAuth, fieldPath.Child("jwt"))...) + fieldCount++ + } + if fieldCount != 1 { - allErrs = append(allErrs, field.Invalid(fieldPath, "", "must specify exactly one of: `accessControl`, `rateLimit`")) + msg := "must specify exactly one of: `accessControl`, `rateLimit`" + if isPlus { + msg = fmt.Sprint(msg, ", `jwt`") + } + + allErrs = append(allErrs, field.Invalid(fieldPath, "", msg)) } return allErrs @@ -95,6 +109,21 @@ func validateRateLimit(rateLimit *v1alpha1.RateLimit, fieldPath *field.Path) fie return allErrs } +func validateJWT(jwt *v1alpha1.JWTAuth, fieldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + allErrs = append(allErrs, validateJWTRealm(jwt.Realm, fieldPath.Child("realm"))...) + + if jwt.Secret == "" { + return append(allErrs, field.Required(fieldPath.Child("secret"), "")) + } + allErrs = append(allErrs, validateSecretName(jwt.Secret, fieldPath.Child("secret"))...) + + allErrs = append(allErrs, validateJWTToken(jwt.Token, fieldPath.Child("token"))...) + + return allErrs +} + const rateFmt = `[1-9]\d*r/[sSmM]` const rateErrMsg = "must consist of numeric characters followed by a valid rate suffix. 'r/s|r/m" @@ -138,7 +167,7 @@ func validateRateLimitZoneSize(zoneSize string, fieldPath *field.Path) field.Err var rateLimitKeySpecialVariables = []string{"arg_", "http_", "cookie_"} -// rateLimitVariables includes NGINX variables allowed to be used in a rateLimit policy key. +// rateLimitKeyVariables includes NGINX variables allowed to be used in a rateLimit policy key. var rateLimitKeyVariables = map[string]bool{ "binary_remote_addr": true, "request_uri": true, @@ -163,6 +192,38 @@ func validateRateLimitKey(key string, fieldPath *field.Path) field.ErrorList { return allErrs } +var jwtTokenSpecialVariables = []string{"arg_", "http_", "cookie_"} + +func validateJWTToken(token string, fieldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + if token == "" { + return allErrs + } + + nginxVars := strings.Split(token, "$") + if len(nginxVars) != 2 { + return append(allErrs, field.Invalid(fieldPath, token, "must have 1 var")) + } + nVar := token[1:] + + special := false + for _, specialVar := range jwtTokenSpecialVariables { + if strings.HasPrefix(nVar, specialVar) { + special = true + break + } + } + + if special { + allErrs = append(allErrs, validateSpecialVariable(nVar, fieldPath)...) + } else { + return append(allErrs, field.Invalid(fieldPath, token, "must only have special vars")) + } + + return allErrs +} + var validLogLevels = map[string]bool{ "info": true, "notice": true, @@ -181,6 +242,26 @@ func validateRateLimitLogLevel(logLevel string, fieldPath *field.Path) field.Err return allErrs } +const jwtRealmFmt = `([^"$\\]|\\[^$])*` +const jwtRealmFmtErrMsg string = `a valid realm must have all '"' escaped and must not contain any '$' or end with an unescaped '\'` + +var jwtRealmFmtRegexp = regexp.MustCompile("^" + jwtRealmFmt + "$") + +func validateJWTRealm(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") + allErrs = append(allErrs, field.Invalid(fieldPath, realm, msg)) + } + + return allErrs +} + func validateIPorCIDR(ipOrCIDR string, fieldPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} diff --git a/pkg/apis/configuration/validation/policy_test.go b/pkg/apis/configuration/validation/policy_test.go index f0addee9d9..8b1f5fb313 100644 --- a/pkg/apis/configuration/validation/policy_test.go +++ b/pkg/apis/configuration/validation/policy_test.go @@ -15,8 +15,9 @@ func TestValidatePolicy(t *testing.T) { }, }, } + isPlus := false - err := ValidatePolicy(policy) + err := ValidatePolicy(policy, isPlus) if err != nil { t.Errorf("ValidatePolicy() returned error %v for valid input", err) } @@ -26,8 +27,9 @@ func TestValidatePolicyFails(t *testing.T) { policy := &v1alpha1.Policy{ Spec: v1alpha1.PolicySpec{}, } + isPlus := false - err := ValidatePolicy(policy) + err := ValidatePolicy(policy, isPlus) if err == nil { t.Errorf("ValidatePolicy() returned no error for invalid input") } @@ -45,7 +47,7 @@ func TestValidatePolicyFails(t *testing.T) { }, } - err = ValidatePolicy(multiPolicy) + err = ValidatePolicy(multiPolicy, isPlus) if err == nil { t.Errorf("ValidatePolicy() returned no error for invalid input") } @@ -221,6 +223,97 @@ func TestValidateRateLimitFails(t *testing.T) { } } +func TestValidateJWT(t *testing.T) { + tests := []struct { + jwt *v1alpha1.JWTAuth + msg string + }{ + { + jwt: &v1alpha1.JWTAuth{ + Realm: "My Product API", + Secret: "my-jwk", + }, + msg: "basic", + }, + { + jwt: &v1alpha1.JWTAuth{ + Realm: "My Product API", + Secret: "my-jwk", + Token: "$cookie_auth_token", + }, + msg: "jwt with token", + }, + } + for _, test := range tests { + allErrs := validateJWT(test.jwt, field.NewPath("jwt")) + if len(allErrs) != 0 { + t.Errorf("validateJWT() returned errors %v for valid input for the case of %v", allErrs, test.msg) + } + } +} + +func TestValidateJWTFails(t *testing.T) { + tests := []struct { + msg string + jwt *v1alpha1.JWTAuth + }{ + { + jwt: &v1alpha1.JWTAuth{ + Realm: "My Product API", + }, + msg: "missing secret", + }, + { + jwt: &v1alpha1.JWTAuth{ + Secret: "my-jwk", + }, + msg: "missing realm", + }, + { + jwt: &v1alpha1.JWTAuth{ + Realm: "My Product API", + Secret: "my-jwk", + Token: "$uri", + }, + msg: "invalid variable use in token", + }, + { + jwt: &v1alpha1.JWTAuth{ + Realm: "My Product API", + Secret: "my-\"jwk", + }, + msg: "invalid secret name", + }, + { + jwt: &v1alpha1.JWTAuth{ + Realm: "My \"Product API", + Secret: "my-jwk", + }, + msg: "invalid realm due to escaped string", + }, + { + jwt: &v1alpha1.JWTAuth{ + Realm: "My Product ${api}", + Secret: "my-jwk", + }, + msg: "invalid variable use in realm with curly braces", + }, + { + jwt: &v1alpha1.JWTAuth{ + Realm: "My Product $api", + Secret: "my-jwk", + }, + msg: "invalid variable use in realm without curly braces", + }, + } + for _, test := range tests { + allErrs := validateJWT(test.jwt, field.NewPath("jwt")) + if len(allErrs) == 0 { + t.Errorf("validateJWT() returned no errors for invalid input for the case of %v", test.msg) + } + } +} + func TestValidateIPorCIDR(t *testing.T) { validInput := []string{ "192.168.1.1", @@ -339,3 +432,65 @@ func TestValidateRateLimitLogLevel(t *testing.T) { } } } + +func TestValidateJWTToken(t *testing.T) { + validTests := []struct { + token string + msg string + }{ + { + token: "", + msg: "no token set", + }, + { + token: "$http_token", + msg: "http special variable usage", + }, + { + token: "$arg_token", + msg: "arg special variable usage", + }, + { + token: "$cookie_token", + msg: "cookie special variable usage", + }, + } + for _, test := range validTests { + allErrs := validateJWTToken(test.token, field.NewPath("token")) + if len(allErrs) != 0 { + t.Errorf("validateJWTToken(%v) returned an error for valid input for the case of %v", test.token, test.msg) + } + } + + invalidTests := []struct { + token string + msg string + }{ + { + token: "http_token", + msg: "missing $ prefix", + }, + { + token: "${http_token}", + msg: "usage of $ and curly braces", + }, + { + token: "$http_token$http_token", + msg: "multi variable usage", + }, + { + token: "something$http_token", + msg: "non variable usage", + }, + { + token: "$uri", + msg: "non special variable usage", + }, + } + for _, test := range invalidTests { + allErrs := validateJWTToken(test.token, field.NewPath("token")) + if len(allErrs) == 0 { + t.Errorf("validateJWTToken(%v) didn't return error for invalid input for the case of %v", test.token, test.msg) + } + } +} diff --git a/pkg/apis/configuration/validation/virtualserver.go b/pkg/apis/configuration/validation/virtualserver.go index 507317f8a4..d8c8a7749b 100644 --- a/pkg/apis/configuration/validation/virtualserver.go +++ b/pkg/apis/configuration/validation/virtualserver.go @@ -417,22 +417,6 @@ func isValidHeaderValue(s string) []string { return nil } -// validateSecretName checks if a secret name is valid. -// It performs the same validation as ValidateSecretName from k8s.io/kubernetes/pkg/apis/core/validation/validation.go. -func validateSecretName(name string, fieldPath *field.Path) field.ErrorList { - allErrs := field.ErrorList{} - - if name == "" { - return allErrs - } - - for _, msg := range validation.IsDNS1123Subdomain(name) { - allErrs = append(allErrs, field.Invalid(fieldPath, name, msg)) - } - - return allErrs -} - func validateUpstreams(upstreams []v1.Upstream, fieldPath *field.Path, isPlus bool) (allErrs field.ErrorList, upstreamNames sets.String) { allErrs = field.ErrorList{} upstreamNames = sets.String{} From 99be30b73708bcad2335e04b06777e20c9e62f04 Mon Sep 17 00:00:00 2001 From: Dean Coakley Date: Tue, 22 Sep 2020 18:14:10 +0100 Subject: [PATCH 2/3] Docs fixes --- docs-web/configuration/policy-resource.md | 2 +- examples-of-custom-resources/jwt/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-web/configuration/policy-resource.md b/docs-web/configuration/policy-resource.md index b3f4e0e99e..5a129df6f9 100644 --- a/docs-web/configuration/policy-resource.md +++ b/docs-web/configuration/policy-resource.md @@ -193,7 +193,7 @@ When you reference more than one rate limit policy, the Ingress Controller will ### JWT -> Note: The feature is only available in NGINX Plus. +> Note: This feature is only available in NGINX Plus. The JWT policy configures NGINX Plus to authenticate client requests using JSON Web Tokens. diff --git a/examples-of-custom-resources/jwt/README.md b/examples-of-custom-resources/jwt/README.md index dd3049e24e..abf07146a7 100644 --- a/examples-of-custom-resources/jwt/README.md +++ b/examples-of-custom-resources/jwt/README.md @@ -30,7 +30,7 @@ $ kubectl apply -f jwk-secret.yaml ## Step 3 - Deploy the JWT Policy -Create a policy with the name `jwt-policy` that references the secret from the previous step and only permits requests to our web application with a valid JWT provided: +Create a policy with the name `jwt-policy` that references the secret from the previous step and only permits requests to our web application that contain a valid JWT: ``` $ kubectl apply -f jwt.yaml ``` From d6923a58752962d240f92ea7215a58d0463629d7 Mon Sep 17 00:00:00 2001 From: Dean Coakley Date: Tue, 22 Sep 2020 18:36:07 +0100 Subject: [PATCH 3/3] Add JWT to policy table. Fix AccessControl fmt. --- docs-web/configuration/policy-resource.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs-web/configuration/policy-resource.md b/docs-web/configuration/policy-resource.md index 5a129df6f9..2ff32ea01f 100644 --- a/docs-web/configuration/policy-resource.md +++ b/docs-web/configuration/policy-resource.md @@ -59,6 +59,10 @@ spec: - The rate limit policy controls the rate of processing requests per a defined key. - `rateLimit <#ratelimit>`_ - No* + * - ``JWT`` + - The JWT policy configures NGINX Plus to authenticate client requests using JSON Web Tokens. + - `jwt <#jwt>`_ + - No* ``` \* A policy must include exactly one policy. @@ -114,6 +118,7 @@ When you reference more than one access control policy, the Ingress Controller w Referencing both allow and deny policies, as shown in the example below, is not supported. If both allow and deny lists are referenced, the Ingress Controller uses just the allow list policies. ```yaml +policies: - name: deny-policy - name: allow-policy-one - name: allow-policy-two @@ -202,6 +207,7 @@ For example, the following policy will reject all requests that do not include a jwt: secret: jwk-secret realm: "My API" + token: $http_token ``` > Note: The feature is implemented using the NGINX Plus [ngx_http_auth_jwt_module](https://nginx.org/en/docs/http/ngx_http_auth_jwt_module.html).