diff --git a/build/scripts/common.sh b/build/scripts/common.sh index 87f69c565e..0fe5559b72 100755 --- a/build/scripts/common.sh +++ b/build/scripts/common.sh @@ -10,6 +10,7 @@ if [ -z "${BUILD_OS##*plus*}" ]; then PLUS=-plus fi +mkdir -p /etc/nginx/njs/ && cp -a /tmp/internal/configs/njs/* /etc/nginx/njs/ mkdir -p /var/lib/nginx /etc/nginx/secrets /etc/nginx/stream-conf.d setcap 'cap_net_bind_service=+eip' /usr/sbin/nginx 'cap_net_bind_service=+eip' /usr/sbin/nginx-debug setcap -v 'cap_net_bind_service=+eip' /usr/sbin/nginx 'cap_net_bind_service=+eip' /usr/sbin/nginx-debug diff --git a/config/crd/bases/k8s.nginx.org_policies.yaml b/config/crd/bases/k8s.nginx.org_policies.yaml index 7fc24a6fae..f275d3a441 100644 --- a/config/crd/bases/k8s.nginx.org_policies.yaml +++ b/config/crd/bases/k8s.nginx.org_policies.yaml @@ -67,6 +67,25 @@ spec: type: string type: array type: object + apiKey: + description: APIKey defines an API Key policy. + properties: + clientSecret: + type: string + suppliedIn: + description: SuppliedIn defines the locations API Key should be + supplied in. + properties: + header: + items: + type: string + type: array + query: + items: + type: string + type: array + type: object + type: object basicAuth: description: |- BasicAuth holds HTTP Basic authentication configuration diff --git a/deploy/crds.yaml b/deploy/crds.yaml index 10d5400dd5..b80f35c763 100644 --- a/deploy/crds.yaml +++ b/deploy/crds.yaml @@ -269,6 +269,25 @@ spec: type: string type: array type: object + apiKey: + description: APIKey defines an API Key policy. + properties: + clientSecret: + type: string + suppliedIn: + description: SuppliedIn defines the locations API Key should be + supplied in. + properties: + header: + items: + type: string + type: array + query: + items: + type: string + type: array + type: object + type: object basicAuth: description: |- BasicAuth holds HTTP Basic authentication configuration diff --git a/docs/content/overview/product-telemetry.md b/docs/content/overview/product-telemetry.md index 0af3d9b9c2..28489cfeec 100644 --- a/docs/content/overview/product-telemetry.md +++ b/docs/content/overview/product-telemetry.md @@ -46,6 +46,7 @@ These are the data points collected and reported by NGINX Ingress Controller: - **IngressAnnotations** List of Ingress annotations managed by NGINX Ingress Controller - **AccessControlPolicies** Number of AccessControl policies. - **RateLimitPolicies** Number of RateLimit policies. +- **APIKeyPolicies** Number of API Key Auth policies. - **JWTAuthPolicies** Number of JWTAuth policies. - **BasicAuthPolicies** Number of BasicAuth policies. - **IngressMTLSPolicies** Number of IngressMTLS policies. diff --git a/examples/custom-resources/api-key/README.md b/examples/custom-resources/api-key/README.md new file mode 100644 index 0000000000..c57b9c4d62 --- /dev/null +++ b/examples/custom-resources/api-key/README.md @@ -0,0 +1,122 @@ +# API Key Authentication + +NGINX supports authenticating requests with +[ngx_http_auth_request_module](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html). In this example, we deploy +a web application, configure load balancing for it via a VirtualServer, and apply an API Key Auth policy. + +## Prerequisites + +1. Follow the [installation](https://docs.nginx.com/nginx-ingress-controller/installation/installation-with-manifests/) + instructions to deploy the Ingress Controller. In this example we will be using a snippet to turn the policy off on a specific path so ensure that the `enable-snippets` flag is set. +1. Save the public IP address of the Ingress Controller into a shell variable: + + ```console + IC_IP=XXX.YYY.ZZZ.III + ``` + +1. Save the HTTP port of the Ingress Controller into a shell variable: + + ```console + IC_HTTP_PORT= + ``` + +## Step 1 - Deploy a Web Application + +Create the application deployment and service: + +```console +kubectl apply -f cafe.yaml +``` + +## Step 2 - Deploy the API Key Auth Secret + +Create a secret of type `nginx.org/apikey` with the name `api-key-client-secret` that will be used for authorization on the server level. + +This secret will contain a mapping of client IDs to base64 encoded API Keys. + +```console +kubectl apply -f api-key-secret.yaml +``` + +## Step 3 - Deploy the API Key Auth Policy + +Create a policy with the name `api-key-policy` that references the secret from the previous step in the clientSecret field. +Provide an array of headers and queries in the header and query fields of the suppliedIn field, indicating where the API key can be sent + +```console +kubectl apply -f api-key-policy.yaml +``` + +## Step 4 - Configure Load Balancing + +Create a VirtualServer resource for the web application: + +```console +kubectl apply -f cafe-virtual-server.yaml +``` + +Note that the VirtualServer references the policy `api-key-policy` created in Step 3. + +## Step 5 - Test the Configuration + +If you attempt to access the application without providing a valid API Key in a expected header or query param for that VirtualServer: + +```console +curl --resolve cafe.example.com:$IC_HTTP_PORT:$IC_IP http://cafe.example.com:$IC_HTTP_PORT/ +``` + +```text + +401 Authorization Required + +

401 Authorization Required

+
nginx/1.21.5
+ + +``` + +If you attempt to access the application providing an incorrect API Key in an expected header or query param for that VirtualServer: + +```console +curl --resolve cafe.example.com:$IC_HTTP_PORT:$IC_IP -H "X-header-name: wrongpassword" http://cafe.example.com:$IC_HTTP_PORT/coffee +``` + +```text + +403 Forbidden + +

403 Forbidden

+
nginx/1.27.0
+ + +``` + +If you provide a valid API Key in an a header or query defined in the policy, your request will succeed: + +```console +curl --resolve cafe.example.com:$IC_HTTPS_PORT:$IC_IP -H "X-header-name: password" https://cafe.example.com:$IC_HTTPS_PORT/coffee +``` + +```text +Server address: 10.244.0.6:8080 +Server name: coffee-56b44d4c55-vjwxd +Date: 13/Jun/2024:13:12:17 +0000 +URI: /coffee +Request ID: 4feedb3265a0430a1f58831d016e846d +``` + +If you attempt to access the /tea path, the request will be allowed without an API Key, because the auth_request directive is turned off for that path with a location snippet: + +```console +curl --resolve cafe.example.com:$IC_HTTP_PORT:$IC_IP http://cafe.example.com:$IC_HTTP_PORT/tea +``` + +```text +Server address: 10.244.0.5:8080 +Server name: tea-596697966f-dmq7t +Date: 13/Jun/2024:13:16:46 +0000 +URI: /tea +Request ID: 26e6d7dd0272eca82f31f33bf90698c9 +``` + +Additionally you can set [error pages](https://docs.nginx.com/nginx-ingress-controller/configuration/virtualserver-and-virtualserverroute-resources/#errorpage) to handle the 401 and 403 responses. diff --git a/examples/custom-resources/api-key/api-key-policy.yaml b/examples/custom-resources/api-key/api-key-policy.yaml new file mode 100644 index 0000000000..1cbf203f45 --- /dev/null +++ b/examples/custom-resources/api-key/api-key-policy.yaml @@ -0,0 +1,12 @@ +apiVersion: k8s.nginx.org/v1 +kind: Policy +metadata: + name: api-key-policy +spec: + apiKey: + suppliedIn: + header: + - "X-header-name" + query: + - "queryName" + clientSecret: api-key-client-secret diff --git a/examples/custom-resources/api-key/api-key-secret.yaml b/examples/custom-resources/api-key/api-key-secret.yaml new file mode 100644 index 0000000000..7ae712d917 --- /dev/null +++ b/examples/custom-resources/api-key/api-key-secret.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: api-key-client-secret +type: nginx.org/apikey +data: + client1: cGFzc3dvcmQ= # password + client2: YW5vdGhlci1wYXNzd29yZA== # another-password diff --git a/examples/custom-resources/api-key/cafe-secret.yaml b/examples/custom-resources/api-key/cafe-secret.yaml new file mode 100644 index 0000000000..8f9fd84855 --- /dev/null +++ b/examples/custom-resources/api-key/cafe-secret.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: cafe-secret +type: kubernetes.io/tls +data: + tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURMakNDQWhZQ0NRREFPRjl0THNhWFdqQU5CZ2txaGtpRzl3MEJBUXNGQURCYU1Rc3dDUVlEVlFRR0V3SlYKVXpFTE1Ba0dBMVVFQ0F3Q1EwRXhJVEFmQmdOVkJBb01HRWx1ZEdWeWJtVjBJRmRwWkdkcGRITWdVSFI1SUV4MApaREViTUJrR0ExVUVBd3dTWTJGbVpTNWxlR0Z0Y0d4bExtTnZiU0FnTUI0WERURTRNRGt4TWpFMk1UVXpOVm9YCkRUSXpNRGt4TVRFMk1UVXpOVm93V0RFTE1Ba0dBMVVFQmhNQ1ZWTXhDekFKQmdOVkJBZ01Ba05CTVNFd0h3WUQKVlFRS0RCaEpiblJsY201bGRDQlhhV1JuYVhSeklGQjBlU0JNZEdReEdUQVhCZ05WQkFNTUVHTmhabVV1WlhoaApiWEJzWlM1amIyMHdnZ0VpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElCRHdBd2dnRUtBb0lCQVFDcDZLbjdzeTgxCnAwanVKL2N5ayt2Q0FtbHNmanRGTTJtdVpOSzBLdGVjcUcyZmpXUWI1NXhRMVlGQTJYT1N3SEFZdlNkd0kyaloKcnVXOHFYWENMMnJiNENaQ0Z4d3BWRUNyY3hkam0zdGVWaVJYVnNZSW1tSkhQUFN5UWdwaW9iczl4N0RsTGM2SQpCQTBaalVPeWwwUHFHOVNKZXhNVjczV0lJYTVyRFZTRjJyNGtTa2JBajREY2o3TFhlRmxWWEgySTVYd1hDcHRDCm42N0pDZzQyZitrOHdnemNSVnA4WFprWldaVmp3cTlSVUtEWG1GQjJZeU4xWEVXZFowZXdSdUtZVUpsc202OTIKc2tPcktRajB2a29QbjQxRUUvK1RhVkVwcUxUUm9VWTNyemc3RGtkemZkQml6Rk8yZHNQTkZ4MkNXMGpYa05MdgpLbzI1Q1pyT2hYQUhBZ01CQUFFd0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dFQkFLSEZDY3lPalp2b0hzd1VCTWRMClJkSEliMzgzcFdGeW5acS9MdVVvdnNWQTU4QjBDZzdCRWZ5NXZXVlZycTVSSWt2NGxaODFOMjl4MjFkMUpINnIKalNuUXgrRFhDTy9USkVWNWxTQ1VwSUd6RVVZYVVQZ1J5anNNL05VZENKOHVIVmhaSitTNkZBK0NuT0Q5cm4yaQpaQmVQQ0k1ckh3RVh3bm5sOHl3aWozdnZRNXpISXV5QmdsV3IvUXl1aTlmalBwd1dVdlVtNG52NVNNRzl6Q1Y3ClBwdXd2dWF0cWpPMTIwOEJqZkUvY1pISWc4SHc5bXZXOXg5QytJUU1JTURFN2IvZzZPY0s3TEdUTHdsRnh2QTgKN1dqRWVxdW5heUlwaE1oS1JYVmYxTjM0OWVOOThFejM4Zk9USFRQYmRKakZBL1BjQytHeW1lK2lHdDVPUWRGaAp5UkU9Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + tls.key: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFb3dJQkFBS0NBUUVBcWVpcCs3TXZOYWRJN2lmM01wUHJ3Z0pwYkg0N1JUTnBybVRTdENyWG5LaHRuNDFrCkcrZWNVTldCUU5semtzQndHTDBuY0NObzJhN2x2S2wxd2k5cTIrQW1RaGNjS1ZSQXEzTVhZNXQ3WGxZa1YxYkcKQ0pwaVJ6ejBza0lLWXFHN1BjZXc1UzNPaUFRTkdZMURzcGRENmh2VWlYc1RGZTkxaUNHdWF3MVVoZHErSkVwRwp3SStBM0kreTEzaFpWVng5aU9WOEZ3cWJRcCt1eVFvT05uL3BQTUlNM0VWYWZGMlpHVm1WWThLdlVWQ2cxNWhRCmRtTWpkVnhGbldkSHNFYmltRkNaYkp1dmRySkRxeWtJOUw1S0Q1K05SQlAvazJsUkthaTAwYUZHTjY4NE93NUgKYzMzUVlzeFR0bmJEelJjZGdsdEkxNURTN3lxTnVRbWF6b1Z3QndJREFRQUJBb0lCQVFDUFNkU1luUXRTUHlxbApGZlZGcFRPc29PWVJoZjhzSStpYkZ4SU91UmF1V2VoaEp4ZG01Uk9ScEF6bUNMeUw1VmhqdEptZTIyM2dMcncyCk45OUVqVUtiL1ZPbVp1RHNCYzZvQ0Y2UU5SNThkejhjbk9SVGV3Y290c0pSMXBuMWhobG5SNUhxSkpCSmFzazEKWkVuVVFmY1hackw5NGxvOUpIM0UrVXFqbzFGRnM4eHhFOHdvUEJxalpzVjdwUlVaZ0MzTGh4bndMU0V4eUZvNApjeGI5U09HNU9tQUpvelN0Rm9RMkdKT2VzOHJKNXFmZHZ5dGdnOXhiTGFRTC94MGtwUTYyQm9GTUJEZHFPZVBXCktmUDV6WjYvMDcvdnBqNDh5QTFRMzJQem9idWJzQkxkM0tjbjMyamZtMUU3cHJ0V2wrSmVPRmlPem5CUUZKYk4KNHFQVlJ6NWhBb0dCQU50V3l4aE5DU0x1NFArWGdLeWNrbGpKNkY1NjY4Zk5qNUN6Z0ZScUowOXpuMFRsc05ybwpGVExaY3hEcW5SM0hQWU00MkpFUmgySi9xREZaeW5SUW8zY2czb2VpdlVkQlZHWTgrRkkxVzBxZHViL0w5K3l1CmVkT1pUUTVYbUdHcDZyNmpleHltY0ppbS9Pc0IzWm5ZT3BPcmxEN1NQbUJ2ek5MazRNRjZneGJYQW9HQkFNWk8KMHA2SGJCbWNQMHRqRlhmY0tFNzdJbUxtMHNBRzR1SG9VeDBlUGovMnFyblRuT0JCTkU0TXZnRHVUSnp5K2NhVQprOFJxbWRIQ2JIelRlNmZ6WXEvOWl0OHNaNzdLVk4xcWtiSWN1YytSVHhBOW5OaDFUanNSbmU3NFowajFGQ0xrCmhIY3FIMHJpN1BZU0tIVEU4RnZGQ3haWWRidUI4NENtWmlodnhicFJBb0dBSWJqcWFNWVBUWXVrbENkYTVTNzkKWVNGSjFKelplMUtqYS8vdER3MXpGY2dWQ0thMzFqQXdjaXowZi9sU1JxM0hTMUdHR21lemhQVlRpcUxmZVpxYwpSMGlLYmhnYk9jVlZrSkozSzB5QXlLd1BUdW14S0haNnpJbVpTMGMwYW0rUlk5WUdxNVQ3WXJ6cHpjZnZwaU9VCmZmZTNSeUZUN2NmQ21mb09oREN0enVrQ2dZQjMwb0xDMVJMRk9ycW40M3ZDUzUxemM1em9ZNDR1QnpzcHd3WU4KVHd2UC9FeFdNZjNWSnJEakJDSCtULzZzeXNlUGJKRUltbHpNK0l3eXRGcEFOZmlJWEV0LzQ4WGY2ME54OGdXTQp1SHl4Wlp4L05LdER3MFY4dlgxUE9ucTJBNWVpS2ErOGpSQVJZS0pMWU5kZkR1d29seHZHNmJaaGtQaS80RXRUCjNZMThzUUtCZ0h0S2JrKzdsTkpWZXN3WEU1Y1VHNkVEVXNEZS8yVWE3ZlhwN0ZjanFCRW9hcDFMU3crNlRYcDAKWmdybUtFOEFSek00NytFSkhVdmlpcS9udXBFMTVnMGtKVzNzeWhwVTl6WkxPN2x0QjBLSWtPOVpSY21Vam84UQpjcExsSE1BcWJMSjhXWUdKQ2toaVd4eWFsNmhZVHlXWTRjVmtDMHh0VGwvaFVFOUllTktvCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg== diff --git a/examples/custom-resources/api-key/cafe-virtual-server.yaml b/examples/custom-resources/api-key/cafe-virtual-server.yaml new file mode 100644 index 0000000000..b523112eb6 --- /dev/null +++ b/examples/custom-resources/api-key/cafe-virtual-server.yaml @@ -0,0 +1,26 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: cafe +spec: + host: cafe.example.com + tls: + secret: cafe-secret + policies: + - name: api-key-policy + upstreams: + - name: coffee + service: coffee-svc + port: 80 + - name: tea + service: tea-svc + port: 80 + routes: + - path: /coffee + action: + pass: coffee + - path: /tea + location-snippets: | + auth_request off; + action: + pass: tea diff --git a/examples/custom-resources/api-key/cafe.yaml b/examples/custom-resources/api-key/cafe.yaml new file mode 100644 index 0000000000..f049e8bf29 --- /dev/null +++ b/examples/custom-resources/api-key/cafe.yaml @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: coffee +spec: + replicas: 2 + selector: + matchLabels: + app: coffee + template: + metadata: + labels: + app: coffee + spec: + containers: + - name: coffee + image: nginxdemos/nginx-hello:plain-text + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: coffee-svc +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: coffee +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tea +spec: + replicas: 1 + selector: + matchLabels: + app: tea + template: + metadata: + labels: + app: tea + spec: + containers: + - name: tea + image: nginxdemos/nginx-hello:plain-text + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: tea-svc +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: tea diff --git a/internal/configs/configurator.go b/internal/configs/configurator.go index d8412b8bba..c891afd4e6 100644 --- a/internal/configs/configurator.go +++ b/internal/configs/configurator.go @@ -1893,6 +1893,9 @@ func (cnf *Configurator) AddOrUpdateSecret(secret *api_v1.Secret) string { case secrets.SecretTypeOIDC: // OIDC ClientSecret is not required on the filesystem, it is written directly to the config file. return "" + case secrets.SecretTypeAPIKey: + // APIKey ClientSecret is not required on the filesystem, it is written directly to the config file. + return "" default: return cnf.addOrUpdateTLSSecret(secret) } diff --git a/internal/configs/njs/apikey_auth.js b/internal/configs/njs/apikey_auth.js new file mode 100644 index 0000000000..459dba9cf8 --- /dev/null +++ b/internal/configs/njs/apikey_auth.js @@ -0,0 +1,26 @@ +const c = require('crypto') + +function hash(r) { + const header_query_value = r.variables.header_query_value; + const hashed_value = c.createHash('sha256').update(header_query_value).digest('hex'); + return hashed_value; +} + +function validate(r) { + const client_name_map = r.variables['apikey_auth_local_map']; + const client_name = r.variables[client_name_map]; + const header_query_value = r.variables.header_query_value; + + if (!header_query_value) { + r.return(401, "401") + } + else if (!client_name) { + r.return(403, "403") + } + else { + r.return(204, "204"); + } + +} + +export default { validate, hash }; diff --git a/internal/configs/version1/nginx-plus.tmpl b/internal/configs/version1/nginx-plus.tmpl index e13589d5b3..c62c8b71f1 100644 --- a/internal/configs/version1/nginx-plus.tmpl +++ b/internal/configs/version1/nginx-plus.tmpl @@ -27,9 +27,7 @@ load_module modules/ngx_fips_check_module.so; {{$value}}{{end}} {{- end}} -{{- if .OIDC}} load_module modules/ngx_http_js_module.so; -{{- end}} events { worker_connections {{.WorkerConnections}}; @@ -41,6 +39,9 @@ http { map_hash_max_size {{.MapHashMaxSize}}; map_hash_bucket_size {{.MapHashBucketSize}}; + js_import /etc/nginx/njs/apikey_auth.js; + js_set $apikey_auth_hash apikey_auth.hash; + {{- if .HTTPSnippets}} {{range $value := .HTTPSnippets}} {{$value}}{{end}} diff --git a/internal/configs/version1/nginx.tmpl b/internal/configs/version1/nginx.tmpl index a3e34a94f9..f4f46243ee 100644 --- a/internal/configs/version1/nginx.tmpl +++ b/internal/configs/version1/nginx.tmpl @@ -20,6 +20,8 @@ load_module modules/ngx_http_opentracing_module.so; {{$value}}{{end}} {{- end}} +load_module modules/ngx_http_js_module.so; + events { worker_connections {{.WorkerConnections}}; } @@ -30,6 +32,10 @@ http { map_hash_max_size {{.MapHashMaxSize}}; map_hash_bucket_size {{.MapHashBucketSize}}; + + js_import /etc/nginx/njs/apikey_auth.js; + js_set $apikey_auth_hash apikey_auth.hash; + {{- if .HTTPSnippets}} {{range $value := .HTTPSnippets}} {{$value}}{{end}} diff --git a/internal/configs/version2/http.go b/internal/configs/version2/http.go index c79590fe5e..f05a999ca9 100644 --- a/internal/configs/version2/http.go +++ b/internal/configs/version2/http.go @@ -84,6 +84,8 @@ type Server struct { IngressMTLS *IngressMTLS EgressMTLS *EgressMTLS OIDC *OIDC + APIKey *APIKey + APIKeyEnabled bool WAF *WAF Dos *Dos PoliciesErrorReturn *Return @@ -137,6 +139,13 @@ type OIDC struct { AccessTokenEnable bool } +// APIKey holds API key configuration. +type APIKey struct { + Header []string + Query []string + MapName string +} + // WAF defines WAF configuration. type WAF struct { Enable string @@ -197,6 +206,7 @@ type Location struct { BasicAuth *BasicAuth EgressMTLS *EgressMTLS OIDC bool + APIKey *APIKey WAF *WAF Dos *Dos PoliciesErrorReturn *Return diff --git a/internal/configs/version2/nginx-plus.virtualserver.tmpl b/internal/configs/version2/nginx-plus.virtualserver.tmpl index b943e2d227..0715915457 100644 --- a/internal/configs/version2/nginx-plus.virtualserver.tmpl +++ b/internal/configs/version2/nginx-plus.virtualserver.tmpl @@ -222,6 +222,13 @@ server { } {{- end }} + {{- if $s.APIKeyEnabled}} + location = /_validate_apikey_njs { + internal; + js_content apikey_auth.validate; + } + {{- end }} + {{- with $s.BasicAuth }} auth_basic {{ printf "%q" .Realm }}; auth_basic_user_file {{ .Secret }}; @@ -245,6 +252,12 @@ server { proxy_ssl_name {{ .SSLName }}; {{- end }} + {{- with $s.APIKey}} + js_var $apikey_auth_local_map "{{ .MapName}}"; + js_var $apikey_auth_token $apikey_auth_hash; + auth_request /_validate_apikey_njs; + {{- end }} + {{- with $s.WAF }} app_protect_enable {{ .Enable }}; {{ if .ApPolicy }} @@ -444,6 +457,19 @@ server { {{- end }} {{- end }} + + {{- with $l.APIKey}} + set $apikey_auth_local_map "{{ .MapName }}"; + set $header_query_value {{ makeHeaderQueryValue $l.APIKey | printf }}; + set $apikey_auth_token $apikey_auth_hash; + auth_request /_validate_apikey_njs; + {{- else }} + {{- with $s.APIKey }} + set $header_query_value {{ makeHeaderQueryValue $s.APIKey | printf }}; + {{- end }} + + {{- end }} + {{- with $l.WAF }} app_protect_enable {{ .Enable }}; {{- if .ApPolicy }} diff --git a/internal/configs/version2/nginx.virtualserver.tmpl b/internal/configs/version2/nginx.virtualserver.tmpl index b558a65e2d..3baf6eaec9 100644 --- a/internal/configs/version2/nginx.virtualserver.tmpl +++ b/internal/configs/version2/nginx.virtualserver.tmpl @@ -143,11 +143,24 @@ server { {{ if $rl.Delay }} delay={{ $rl.Delay }}{{ end }}{{ if $rl.NoDelay }} nodelay{{ end }}; {{- end }} + {{- if $s.APIKeyEnabled}} + location = /_validate_apikey_njs { + internal; + js_content apikey_auth.validate; + } + {{- end }} + {{- with $s.BasicAuth }} auth_basic {{ printf "%q" .Realm }}; auth_basic_user_file {{ .Secret }}; {{- end }} + {{- with $s.APIKey}} + js_var $apikey_auth_local_map "{{ .MapName}}"; + js_var $apikey_auth_token $apikey_auth_hash; + auth_request /_validate_apikey_njs; + {{- end }} + {{- with $s.EgressMTLS }} {{- if .Certificate }} proxy_ssl_certificate {{ makeSecretPath .Certificate $.StaticSSLPath "$secret_dir_path" $.DynamicSSLReloadEnabled }}; @@ -255,6 +268,18 @@ server { auth_basic_user_file {{ .Secret }}; {{- end }} + {{- with $l.APIKey}} + set $apikey_auth_local_map "{{ .MapName }}"; + set $header_query_value {{ makeHeaderQueryValue $l.APIKey | printf }}; + set $apikey_auth_token $apikey_auth_hash; + auth_request /_validate_apikey_njs; + + {{- else }} + {{- with $s.APIKey }} + set $header_query_value {{ makeHeaderQueryValue $s.APIKey | printf }}; + {{- end }} + {{- end }} + {{ $proxyOrGRPC := "proxy" }}{{ if $l.GRPCPass }}{{ $proxyOrGRPC = "grpc" }}{{ end }} {{- with $l.EgressMTLS }} diff --git a/internal/configs/version2/template_helper.go b/internal/configs/version2/template_helper.go index f49466875e..1f9356959f 100644 --- a/internal/configs/version2/template_helper.go +++ b/internal/configs/version2/template_helper.go @@ -1,6 +1,7 @@ package version2 import ( + "fmt" "strconv" "strings" "text/template" @@ -121,16 +122,34 @@ func makeHTTPSListener(s Server) string { return makeListener(https, s) } +func makeHeaderQueryValue(apiKey APIKey) string { + var parts []string + + for _, header := range apiKey.Header { + nginxHeader := strings.ReplaceAll(header, "-", "_") + nginxHeader = strings.ToLower(nginxHeader) + + parts = append(parts, fmt.Sprintf("${http_%s}", nginxHeader)) + } + + for _, query := range apiKey.Query { + parts = append(parts, fmt.Sprintf("${arg_%s}", query)) + } + + return fmt.Sprintf("\"%s\"", strings.Join(parts, "")) +} + var helperFunctions = template.FuncMap{ - "headerListToCIMap": headerListToCIMap, - "hasCIKey": hasCIKey, - "contains": strings.Contains, - "hasPrefix": strings.HasPrefix, - "hasSuffix": strings.HasSuffix, - "toLower": strings.ToLower, - "toUpper": strings.ToUpper, - "replaceAll": strings.ReplaceAll, - "makeHTTPListener": makeHTTPListener, - "makeHTTPSListener": makeHTTPSListener, - "makeSecretPath": commonhelpers.MakeSecretPath, + "headerListToCIMap": headerListToCIMap, + "hasCIKey": hasCIKey, + "contains": strings.Contains, + "hasPrefix": strings.HasPrefix, + "hasSuffix": strings.HasSuffix, + "toLower": strings.ToLower, + "toUpper": strings.ToUpper, + "replaceAll": strings.ReplaceAll, + "makeHTTPListener": makeHTTPListener, + "makeHTTPSListener": makeHTTPSListener, + "makeSecretPath": commonhelpers.MakeSecretPath, + "makeHeaderQueryValue": makeHeaderQueryValue, } diff --git a/internal/configs/version2/template_helper_test.go b/internal/configs/version2/template_helper_test.go index 8b0e9b99d9..ed0650ae06 100644 --- a/internal/configs/version2/template_helper_test.go +++ b/internal/configs/version2/template_helper_test.go @@ -4,6 +4,8 @@ import ( "bytes" "testing" "text/template" + + "github.com/google/go-cmp/cmp" ) func TestContainsSubstring(t *testing.T) { @@ -303,6 +305,42 @@ func TestReplaceAll(t *testing.T) { } } +func TestMakeHeaderQueryValue(t *testing.T) { + t.Parallel() + + testCases := []struct { + apiKey APIKey + expected string + }{ + { + apiKey: APIKey{ + Header: []string{"foo", "bar"}, + }, + expected: `"${http_foo}${http_bar}"`, + }, + { + apiKey: APIKey{ + Header: []string{"foo", "bar"}, + Query: []string{"baz", "qux"}, + }, + expected: `"${http_foo}${http_bar}${arg_baz}${arg_qux}"`, + }, + { + apiKey: APIKey{ + Query: []string{"baz", "qux"}, + }, + expected: `"${arg_baz}${arg_qux}"`, + }, + } + + for _, tc := range testCases { + got := makeHeaderQueryValue(tc.apiKey) + if !cmp.Equal(tc.expected, got) { + t.Error(cmp.Diff(tc.expected, got)) + } + } +} + func newHasPrefixTemplate(t *testing.T) *template.Template { t.Helper() tmpl, err := template.New("testTemplate").Funcs(helperFunctions).Parse(`{{hasPrefix .InputString .Prefix}}`) diff --git a/internal/configs/virtualserver.go b/internal/configs/virtualserver.go index 64e819bfda..de5fba197f 100644 --- a/internal/configs/virtualserver.go +++ b/internal/configs/virtualserver.go @@ -1,6 +1,8 @@ package configs import ( + "crypto/sha256" + "encoding/hex" "fmt" "net/url" "os" @@ -421,6 +423,12 @@ func (vsc *virtualServerConfigurator) GenerateVirtualServerConfig( policiesCfg.JWTAuthList[jwtAuthKey] = policiesCfg.JWTAuth } + if policiesCfg.APIKeyEnabled { + apiMapName := policiesCfg.APIKey.MapName + policiesCfg.APIKeyClientMap = make(map[string][]apiKeyClient) + policiesCfg.APIKeyClientMap[apiMapName] = policiesCfg.APIKeyClients + } + dosCfg := generateDosCfg(dosResources[""]) // enabledInternalRoutes controls if a virtual server is configured as an internal route. @@ -580,6 +588,16 @@ func (vsc *virtualServerConfigurator) GenerateVirtualServerConfig( policiesCfg.JWTAuthList[jwtAuthKey] = routePoliciesCfg.JWTAuth } } + if routePoliciesCfg.APIKeyEnabled { + policiesCfg.APIKeyEnabled = routePoliciesCfg.APIKeyEnabled + apiMapName := routePoliciesCfg.APIKey.MapName + if policiesCfg.APIKeyClientMap == nil { + policiesCfg.APIKeyClientMap = make(map[string][]apiKeyClient) + } + if _, exists := policiesCfg.APIKeyClientMap[apiMapName]; !exists { + policiesCfg.APIKeyClientMap[apiMapName] = routePoliciesCfg.APIKeyClients + } + } limitReqZones = append(limitReqZones, routePoliciesCfg.LimitReqZones...) dosRouteCfg := generateDosCfg(dosResources[r.Path]) @@ -710,6 +728,17 @@ func (vsc *virtualServerConfigurator) GenerateVirtualServerConfig( policiesCfg.JWTAuthList[jwtAuthKey] = routePoliciesCfg.JWTAuth } } + if routePoliciesCfg.APIKeyEnabled { + policiesCfg.APIKeyEnabled = routePoliciesCfg.APIKeyEnabled + apiMapName := routePoliciesCfg.APIKey.MapName + if policiesCfg.APIKeyClientMap == nil { + policiesCfg.APIKeyClientMap = make(map[string][]apiKeyClient) + } + if _, exists := policiesCfg.APIKeyClientMap[apiMapName]; !exists { + policiesCfg.APIKeyClientMap[apiMapName] = routePoliciesCfg.APIKeyClients + } + } + limitReqZones = append(limitReqZones, routePoliciesCfg.LimitReqZones...) dosRouteCfg := generateDosCfg(dosResources[r.Path]) @@ -777,6 +806,10 @@ func (vsc *virtualServerConfigurator) GenerateVirtualServerConfig( } } + for mapName, apiKeyClients := range policiesCfg.APIKeyClientMap { + maps = append(maps, *generateAPIKeyClientMap(mapName, apiKeyClients)) + } + httpSnippets := generateSnippets(vsc.enableSnippets, vsEx.VirtualServer.Spec.HTTPSnippets, []string{}) serverSnippets := generateSnippets( vsc.enableSnippets, @@ -826,6 +859,8 @@ func (vsc *virtualServerConfigurator) GenerateVirtualServerConfig( JWKSAuthEnabled: policiesCfg.JWKSAuthEnabled, IngressMTLS: policiesCfg.IngressMTLS, EgressMTLS: policiesCfg.EgressMTLS, + APIKey: policiesCfg.APIKey, + APIKeyEnabled: policiesCfg.APIKeyEnabled, OIDC: vsc.oidcPolCfg.oidc, WAF: policiesCfg.WAF, Dos: dosCfg, @@ -859,6 +894,10 @@ type policiesCfg struct { IngressMTLS *version2.IngressMTLS EgressMTLS *version2.EgressMTLS OIDC bool + APIKeyEnabled bool + APIKey *version2.APIKey + APIKeyClients []apiKeyClient + APIKeyClientMap map[string][]apiKeyClient WAF *version2.WAF ErrorReturn *version2.Return BundleValidator bundleValidator @@ -873,6 +912,11 @@ type internalBundleValidator struct { bundlePath string } +type apiKeyClient struct { + ClientID string + HashedKey string +} + func (i internalBundleValidator) validate(bundle string) (string, error) { bundle = path.Join(i.bundlePath, bundle) _, err := os.Stat(bundle) @@ -1290,6 +1334,96 @@ func (p *policiesCfg) addOIDCConfig( return res } +func (p *policiesCfg) addAPIKeyConfig( + apiKey *conf_v1.APIKey, + polKey string, + polNamespace string, + vsNamespace string, + vsName string, + secretRefs map[string]*secrets.SecretReference, +) *validationResults { + res := newValidationResults() + if p.APIKey != nil { + res.addWarningf( + "Multiple API Key policies in the same context is not valid. API Key policy %s will be ignored", + polKey, + ) + res.isError = true + return res + } + + secretKey := fmt.Sprintf("%v/%v", polNamespace, apiKey.ClientSecret) + secretRef := secretRefs[secretKey] + var secretType api_v1.SecretType + if secretRef.Secret != nil { + secretType = secretRef.Secret.Type + } + if secretType != "" && secretType != secrets.SecretTypeAPIKey { + res.addWarningf("API Key policy %s references a secret %s of a wrong type '%s', must be '%s'", polKey, secretKey, secretType, secrets.SecretTypeAPIKey) + res.isError = true + return res + } else if secretRef.Error != nil { + res.addWarningf("API Key %s references an invalid secret %s: %v", polKey, secretKey, secretRef.Error) + res.isError = true + return res + } + + p.APIKeyClients = generateAPIKeyClients(secretRef.Secret.Data) + + mapName := fmt.Sprintf( + "apikey_auth_client_name_%s_%s_%s", + rfc1123ToSnake(vsNamespace), + rfc1123ToSnake(vsName), + strings.Split(rfc1123ToSnake(polKey), "/")[1], + ) + p.APIKey = &version2.APIKey{ + Header: apiKey.SuppliedIn.Header, + Query: apiKey.SuppliedIn.Query, + MapName: mapName, + } + p.APIKeyEnabled = true + return res +} + +func rfc1123ToSnake(rfc1123String string) string { + return strings.Replace(rfc1123String, "-", "_", -1) +} + +func generateAPIKeyClients(secretData map[string][]byte) []apiKeyClient { + var clients []apiKeyClient + for clientID, apiKey := range secretData { + + h := sha256.New() + h.Write(apiKey) + sha256Hash := hex.EncodeToString(h.Sum(nil)) + clients = append(clients, apiKeyClient{ClientID: clientID, HashedKey: sha256Hash}) // + } + return clients +} + +func generateAPIKeyClientMap(mapName string, apiKeyClients []apiKeyClient) *version2.Map { + defaultParam := version2.Parameter{ + Value: "default", + Result: "\"\"", + } + + params := []version2.Parameter{defaultParam} + for _, client := range apiKeyClients { + params = append(params, version2.Parameter{ + Value: fmt.Sprintf("\"%s\"", client.HashedKey), + Result: fmt.Sprintf("\"%s\"", client.ClientID), + }) + } + + sourceName := "$apikey_auth_token" + + return &version2.Map{ + Source: sourceName, + Variable: fmt.Sprintf("$%s", mapName), + Parameters: params, + } +} + func (p *policiesCfg) addWAFConfig( waf *conf_v1.WAF, polKey string, @@ -1420,6 +1554,9 @@ func (vsc *virtualServerConfigurator) generatePolicies( res = config.addEgressMTLSConfig(pol.Spec.EgressMTLS, key, polNamespace, policyOpts.secretRefs) case pol.Spec.OIDC != nil: res = config.addOIDCConfig(pol.Spec.OIDC, key, polNamespace, policyOpts.secretRefs, vsc.oidcPolCfg) + case pol.Spec.APIKey != nil: + res = config.addAPIKeyConfig(pol.Spec.APIKey, key, polNamespace, ownerDetails.vsNamespace, + ownerDetails.vsName, policyOpts.secretRefs) case pol.Spec.WAF != nil: res = config.addWAFConfig(pol.Spec.WAF, key, polNamespace, policyOpts.apResources) default: @@ -1507,6 +1644,7 @@ func addPoliciesCfgToLocation(cfg policiesCfg, location *version2.Location) { location.EgressMTLS = cfg.EgressMTLS location.OIDC = cfg.OIDC location.WAF = cfg.WAF + location.APIKey = cfg.APIKey location.PoliciesErrorReturn = cfg.ErrorReturn } diff --git a/internal/configs/virtualserver_test.go b/internal/configs/virtualserver_test.go index 22cc242b48..f03e313e76 100644 --- a/internal/configs/virtualserver_test.go +++ b/internal/configs/virtualserver_test.go @@ -5653,6 +5653,568 @@ func TestGenerateVirtualServerConfigJWKSPolicy(t *testing.T) { } } +func TestGenerateVirtualServerConfigAPIKeyPolicy(t *testing.T) { + t.Parallel() + + virtualServerEx := VirtualServerEx{ + SecretRefs: map[string]*secrets.SecretReference{ + "default/api-key-secret-spec": { + Secret: &api_v1.Secret{ + Type: secrets.SecretTypeAPIKey, + Data: map[string][]byte{ + "clientSpec": []byte("password"), + }, + }, + }, + "default/api-key-secret-route": { + Secret: &api_v1.Secret{ + Type: secrets.SecretTypeAPIKey, + Data: map[string][]byte{ + "clientRoute": []byte("password2"), + }, + }, + }, + }, + VirtualServer: &conf_v1.VirtualServer{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "cafe", + Namespace: "default", + }, + Spec: conf_v1.VirtualServerSpec{ + Host: "cafe.example.com", + Policies: []conf_v1.PolicyReference{ + { + Name: "api-key-policy-spec", + }, + }, + Upstreams: []conf_v1.Upstream{ + { + Name: "tea", + Service: "tea-svc", + Port: 80, + }, + { + Name: "coffee", + Service: "coffee-svc", + Port: 80, + }, + }, + Routes: []conf_v1.Route{ + { + Path: "/tea", + Action: &conf_v1.Action{ + Pass: "tea", + }, + }, + { + Path: "/coffee", + Action: &conf_v1.Action{ + Pass: "coffee", + }, + Policies: []conf_v1.PolicyReference{ + { + Name: "api-key-policy-route", + }, + }, + }, + }, + }, + }, + Policies: map[string]*conf_v1.Policy{ + "default/api-key-policy-spec": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "api-key-policy-spec", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + APIKey: &conf_v1.APIKey{ + SuppliedIn: &conf_v1.SuppliedIn{ + Header: []string{"X-API-Key"}, + Query: []string{"apikey"}, + }, + ClientSecret: "api-key-secret-spec", + }, + }, + }, + "default/api-key-policy-route": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "api-key-policy-route", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + APIKey: &conf_v1.APIKey{ + SuppliedIn: &conf_v1.SuppliedIn{ + Query: []string{"api-key"}, + }, + ClientSecret: "api-key-secret-route", + }, + }, + }, + }, + Endpoints: map[string][]string{ + "default/tea-svc:80": { + "10.0.0.20:80", + }, + "default/coffee-svc:80": { + "10.0.0.30:80", + }, + }, + } + + expected := version2.VirtualServerConfig{ + Maps: []version2.Map{ + { + Source: "$apikey_auth_token", + Variable: "$apikey_auth_client_name_default_cafe_api_key_policy_route", + Parameters: []version2.Parameter{ + { + Value: "default", + Result: `""`, + }, + { + Value: `"6cf615d5bcaac778352a8f1f3360d23f02f34ec182e259897fd6ce485d7870d4"`, + Result: `"clientRoute"`, + }, + }, + }, + { + Source: "$apikey_auth_token", + Variable: "$apikey_auth_client_name_default_cafe_api_key_policy_spec", + Parameters: []version2.Parameter{ + { + Value: "default", + Result: `""`, + }, + { + Value: `"5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"`, + Result: `"clientSpec"`, + }, + }, + }, + }, + Upstreams: []version2.Upstream{ + { + UpstreamLabels: version2.UpstreamLabels{ + Service: "coffee-svc", + ResourceType: "virtualserver", + ResourceName: "cafe", + ResourceNamespace: "default", + }, + Name: "vs_default_cafe_coffee", + Servers: []version2.UpstreamServer{ + { + Address: "10.0.0.30:80", + }, + }, + Keepalive: 16, + }, + { + UpstreamLabels: version2.UpstreamLabels{ + Service: "tea-svc", + ResourceType: "virtualserver", + ResourceName: "cafe", + ResourceNamespace: "default", + }, + Name: "vs_default_cafe_tea", + Servers: []version2.UpstreamServer{ + { + Address: "10.0.0.20:80", + }, + }, + Keepalive: 16, + }, + }, + HTTPSnippets: []string{}, + LimitReqZones: []version2.LimitReqZone{}, + Server: version2.Server{ + JWTAuthList: nil, + JWTAuth: nil, + JWKSAuthEnabled: false, + ServerName: "cafe.example.com", + StatusZone: "cafe.example.com", + ProxyProtocol: true, + ServerTokens: "off", + RealIPHeader: "X-Real-IP", + SetRealIPFrom: []string{"0.0.0.0/0"}, + RealIPRecursive: true, + Snippets: []string{"# server snippet"}, + TLSPassthrough: true, + VSNamespace: "default", + VSName: "cafe", + APIKeyEnabled: true, + APIKey: &version2.APIKey{ + Header: []string{"X-API-Key"}, + Query: []string{"apikey"}, + MapName: "apikey_auth_client_name_default_cafe_api_key_policy_spec", + }, + Locations: []version2.Location{ + { + Path: "/tea", + ProxyPass: "http://vs_default_cafe_tea", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "0s", + ProxyNextUpstreamTries: 0, + HasKeepalive: true, + ProxySSLName: "tea-svc.default.svc", + ProxyPassRequestHeaders: true, + ProxySetHeaders: []version2.Header{{Name: "Host", Value: "$host"}}, + ServiceName: "tea-svc", + }, + { + Path: "/coffee", + ProxyPass: "http://vs_default_cafe_coffee", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "0s", + ProxyNextUpstreamTries: 0, + HasKeepalive: true, + ProxySSLName: "coffee-svc.default.svc", + ProxyPassRequestHeaders: true, + ProxySetHeaders: []version2.Header{{Name: "Host", Value: "$host"}}, + ServiceName: "coffee-svc", + APIKey: &version2.APIKey{ + MapName: "apikey_auth_client_name_default_cafe_api_key_policy_route", + Query: []string{"api-key"}, + }, + }, + }, + }, + } + + baseCfgParams := ConfigParams{ + ServerTokens: "off", + Keepalive: 16, + ServerSnippets: []string{"# server snippet"}, + ProxyProtocol: true, + SetRealIPFrom: []string{"0.0.0.0/0"}, + RealIPHeader: "X-Real-IP", + RealIPRecursive: true, + } + + vsc := newVirtualServerConfigurator( + &baseCfgParams, + false, + false, + &StaticConfigParams{TLSPassthrough: true}, + false, + &fakeBV, + ) + + result, warnings := vsc.GenerateVirtualServerConfig(&virtualServerEx, nil, nil) + + sort.Slice(result.Maps, func(i, j int) bool { + return result.Maps[i].Variable < result.Maps[j].Variable + }) + + if diff := cmp.Diff(expected, result); diff != "" { + t.Errorf("GenerateVirtualServerConfig() mismatch (-want +got):\n%s", diff) + } + + if len(warnings) != 0 { + t.Errorf("GenerateVirtualServerConfig returned warnings: %v", vsc.warnings) + } +} + +func TestGenerateVirtualServerConfigAPIKeyClientMaps(t *testing.T) { + t.Parallel() + + virtualServerEx := VirtualServerEx{ + SecretRefs: map[string]*secrets.SecretReference{ + "default/api-key-secret-1": { + Secret: &api_v1.Secret{ + Type: secrets.SecretTypeAPIKey, + Data: map[string][]byte{ + "client1": []byte("password"), + }, + }, + }, + "default/api-key-secret-2": { + Secret: &api_v1.Secret{ + Type: secrets.SecretTypeAPIKey, + Data: map[string][]byte{ + "client2": []byte("password2"), + }, + }, + }, + }, + VirtualServer: &conf_v1.VirtualServer{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "cafe", + Namespace: "default", + }, + Spec: conf_v1.VirtualServerSpec{ + Host: "cafe.example.com", + Upstreams: []conf_v1.Upstream{ + { + Name: "tea", + Service: "tea-svc", + Port: 80, + }, + { + Name: "coffee", + Service: "coffee-svc", + Port: 80, + }, + }, + Routes: []conf_v1.Route{ + { + Path: "/tea", + Action: &conf_v1.Action{ + Pass: "tea", + }, + }, + { + Path: "/coffee", + Action: &conf_v1.Action{ + Pass: "coffee", + }, + }, + }, + }, + }, + Policies: map[string]*conf_v1.Policy{ + "default/api-key-policy-1": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "api-key-policy-1", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + APIKey: &conf_v1.APIKey{ + SuppliedIn: &conf_v1.SuppliedIn{ + Header: []string{"X-API-Key"}, + Query: []string{"apikey"}, + }, + ClientSecret: "api-key-secret-1", + }, + }, + }, + "default/api-key-policy-2": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "api-key-policy-2", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + APIKey: &conf_v1.APIKey{ + SuppliedIn: &conf_v1.SuppliedIn{ + Header: []string{"api-key"}, + }, + ClientSecret: "api-key-secret-2", + }, + }, + }, + }, + Endpoints: map[string][]string{ + "default/tea-svc:80": { + "10.0.0.20:80", + }, + "default/coffee-svc:80": { + "10.0.0.30:80", + }, + }, + } + + expectedAPIKey1 := &version2.APIKey{ + MapName: "apikey_auth_client_name_default_cafe_api_key_policy_1", + Header: []string{"X-API-Key"}, + Query: []string{"apikey"}, + } + + expectedAPIKey2 := &version2.APIKey{ + MapName: "apikey_auth_client_name_default_cafe_api_key_policy_2", + Header: []string{"api-key"}, + } + + expectedMap1 := version2.Map{ + Source: "$apikey_auth_token", + Variable: "$apikey_auth_client_name_default_cafe_api_key_policy_1", + Parameters: []version2.Parameter{ + { + Value: "default", + Result: `""`, + }, + { + Value: `"5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"`, + Result: `"client1"`, + }, + }, + } + + expectedMap2 := version2.Map{ + Source: "$apikey_auth_token", + Variable: "$apikey_auth_client_name_default_cafe_api_key_policy_2", + Parameters: []version2.Parameter{ + { + Value: "default", + Result: `""`, + }, + { + Value: `"6cf615d5bcaac778352a8f1f3360d23f02f34ec182e259897fd6ce485d7870d4"`, + Result: `"client2"`, + }, + }, + } + + baseCfgParams := ConfigParams{ + ServerTokens: "off", + Keepalive: 16, + ServerSnippets: []string{"# server snippet"}, + ProxyProtocol: true, + SetRealIPFrom: []string{"0.0.0.0/0"}, + RealIPHeader: "X-Real-IP", + RealIPRecursive: true, + } + + vsc := newVirtualServerConfigurator( + &baseCfgParams, + false, + false, + &StaticConfigParams{TLSPassthrough: true}, + false, + &fakeBV, + ) + + tests := []struct { + specPolicies []conf_v1.PolicyReference + route1Policies []conf_v1.PolicyReference + route2Policies []conf_v1.PolicyReference + expectedSpecAPIKey *version2.APIKey + expectedRoute1APIKey *version2.APIKey + expectedRoute2APIKey *version2.APIKey + expectedMapList []version2.Map + name string + }{ + { + specPolicies: []conf_v1.PolicyReference{ + { + Name: "api-key-policy-1", + }, + }, + route1Policies: []conf_v1.PolicyReference{ + { + Name: "api-key-policy-2", + }, + }, + route2Policies: nil, + expectedSpecAPIKey: expectedAPIKey1, + expectedRoute1APIKey: expectedAPIKey2, + expectedRoute2APIKey: nil, + expectedMapList: []version2.Map{expectedMap1, expectedMap2}, + name: "policy in spec, route 1 and route 2", + }, + { + specPolicies: nil, + route1Policies: []conf_v1.PolicyReference{ + { + Name: "api-key-policy-1", + }, + }, + route2Policies: nil, + expectedSpecAPIKey: nil, + + expectedRoute1APIKey: expectedAPIKey1, + expectedRoute2APIKey: nil, + expectedMapList: []version2.Map{expectedMap1}, + name: "policy in route 1 only", + }, + { + specPolicies: []conf_v1.PolicyReference{ + { + Name: "api-key-policy-2", + }, + }, + route1Policies: nil, + route2Policies: nil, + expectedSpecAPIKey: expectedAPIKey2, + expectedRoute1APIKey: nil, + expectedRoute2APIKey: nil, + expectedMapList: []version2.Map{expectedMap2}, + name: "policy in spec only", + }, + { + specPolicies: nil, + route1Policies: nil, + route2Policies: nil, + expectedRoute1APIKey: nil, + expectedRoute2APIKey: nil, + expectedMapList: nil, + name: "no policies", + }, + } + + invalidTests := []struct { + specPolicies []conf_v1.PolicyReference + teaPolicies []conf_v1.PolicyReference + coffeePolicies []conf_v1.PolicyReference + expectedMapList []version2.Map + expectedWarnings Warnings + name string + }{ + { + specPolicies: []conf_v1.PolicyReference{ + { + Name: "api-key-policy-3", + }, + }, + coffeePolicies: nil, + teaPolicies: nil, + // expectedTeaPolicy: expectedAPIKey2, + // expectedCoffeePolicy: expectedAPIKey1, + expectedMapList: nil, + expectedWarnings: Warnings{ + nil: { + "Policy default/api-key-policy-3 is missing or invalid", + }, + }, + name: "policy does not exist", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + virtualServerEx.VirtualServer.Spec.Policies = tc.specPolicies + virtualServerEx.VirtualServer.Spec.Routes[0].Policies = tc.route1Policies + virtualServerEx.VirtualServer.Spec.Routes[1].Policies = tc.route2Policies + vsConf, warnings := vsc.GenerateVirtualServerConfig(&virtualServerEx, nil, nil) + + sort.Slice(vsConf.Maps, func(i, j int) bool { + return vsConf.Maps[i].Variable < vsConf.Maps[j].Variable + }) + + if !cmp.Equal(tc.expectedSpecAPIKey, vsConf.Server.APIKey) { + t.Errorf(cmp.Diff(tc.expectedSpecAPIKey, vsConf.Server.APIKey)) + } + + if !cmp.Equal(tc.expectedRoute1APIKey, vsConf.Server.Locations[0].APIKey) { + t.Errorf(cmp.Diff(tc.expectedRoute1APIKey, vsConf.Server.Locations[0].APIKey)) + } + + if !cmp.Equal(tc.expectedRoute2APIKey, vsConf.Server.Locations[1].APIKey) { + t.Errorf(cmp.Diff(tc.expectedRoute2APIKey, vsConf.Server.Locations[1].APIKey)) + } + + if !cmp.Equal(tc.expectedMapList, vsConf.Maps) { + t.Errorf(cmp.Diff(tc.expectedMapList, vsConf.Maps)) + } + + if len(warnings) != 0 { + t.Errorf("GenerateVirtualServerConfig returned warnings: %v", vsc.warnings) + } + }) + + for _, tc := range invalidTests { + t.Run(tc.name, func(t *testing.T) { + virtualServerEx.VirtualServer.Spec.Policies = tc.specPolicies + virtualServerEx.VirtualServer.Spec.Routes[0].Policies = tc.teaPolicies + virtualServerEx.VirtualServer.Spec.Routes[1].Policies = tc.coffeePolicies + _, warnings := vsc.GenerateVirtualServerConfig(&virtualServerEx, nil, nil) + + if len(warnings) == 0 { + t.Errorf("GenerateVirtualServerConfig() does not return the expected error %v", tc.expectedWarnings) + } + }) + } + } +} + func TestGeneratePolicies(t *testing.T) { t.Parallel() ownerDetails := policyOwnerDetails{ @@ -5720,6 +6282,22 @@ func TestGeneratePolicies(t *testing.T) { }, }, }, + "default/api-key-secret": { + Secret: &api_v1.Secret{ + Type: secrets.SecretTypeAPIKey, + Data: map[string][]byte{ + "client1": []byte("password"), + }, + }, + }, + "default/api-key-secret-2": { + Secret: &api_v1.Secret{ + Type: secrets.SecretTypeAPIKey, + Data: map[string][]byte{ + "client2": []byte("password2"), + }, + }, + }, }, apResources: &appProtectResourcesForVS{ Policies: map[string]string{ @@ -6287,6 +6865,88 @@ func TestGeneratePolicies(t *testing.T) { }, msg: "oidc reference", }, + { + policyRefs: []conf_v1.PolicyReference{ + { + Name: "api-key-policy", + Namespace: "default", + }, + }, + policies: map[string]*conf_v1.Policy{ + "default/api-key-policy": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "api-key-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + APIKey: &conf_v1.APIKey{ + SuppliedIn: &conf_v1.SuppliedIn{ + Header: []string{"X-API-Key"}, + Query: []string{"api-key"}, + }, + ClientSecret: "api-key-secret", + }, + }, + }, + }, + expected: policiesCfg{ + APIKey: &version2.APIKey{ + Header: []string{"X-API-Key"}, + Query: []string{"api-key"}, + MapName: "apikey_auth_client_name_default_test_api_key_policy", + }, + APIKeyEnabled: true, + APIKeyClientMap: nil, + APIKeyClients: []apiKeyClient{ + { + ClientID: "client1", + HashedKey: "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", + }, + }, + }, + msg: "api key reference", + }, + { + policyRefs: []conf_v1.PolicyReference{ + { + Name: "api-key-policy", + Namespace: "default", + }, + }, + policies: map[string]*conf_v1.Policy{ + "default/api-key-policy": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "api-key-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + APIKey: &conf_v1.APIKey{ + SuppliedIn: &conf_v1.SuppliedIn{ + Header: []string{"X-API-Key"}, + Query: []string{"api-key"}, + }, + ClientSecret: "api-key-secret", + }, + }, + }, + }, + expected: policiesCfg{ + APIKey: &version2.APIKey{ + Header: []string{"X-API-Key"}, + Query: []string{"api-key"}, + MapName: "apikey_auth_client_name_default_test_api_key_policy", + }, + APIKeyEnabled: true, + APIKeyClientMap: nil, + APIKeyClients: []apiKeyClient{ + { + ClientID: "client1", + HashedKey: "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", + }, + }, + }, + msg: "api key same secrets for different policies", + }, { policyRefs: []conf_v1.PolicyReference{ { @@ -6326,15 +6986,18 @@ func TestGeneratePolicies(t *testing.T) { // required to test the scaling of the ratelimit vsc.IngressControllerReplicas = 2 - for _, test := range tests { - result := vsc.generatePolicies(ownerDetails, test.policyRefs, test.policies, test.context, policyOpts) - result.BundleValidator = nil - if diff := cmp.Diff(test.expected, result); diff != "" { - t.Errorf("generatePolicies() '%v' mismatch (-want +got):\n%s", test.msg, diff) - } - if len(vsc.warnings) > 0 { - t.Errorf("generatePolicies() returned unexpected warnings %v for the case of %s", vsc.warnings, test.msg) - } + for _, tc := range tests { + t.Run(tc.msg, func(t *testing.T) { + result := vsc.generatePolicies(ownerDetails, tc.policyRefs, tc.policies, tc.context, policyOpts) + result.BundleValidator = nil + + if !cmp.Equal(tc.expected, result) { + t.Errorf(cmp.Diff(tc.expected, result)) + } + if len(vsc.warnings) > 0 { + t.Errorf("generatePolicies() returned unexpected warnings %v for the case of %s", vsc.warnings, tc.msg) + } + }) } } @@ -6420,10 +7083,10 @@ func TestGeneratePolicies_GeneratesWAFPolicyOnValidApBundle(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { vsc := newVirtualServerConfigurator(&ConfigParams{}, false, false, &StaticConfigParams{}, false, &fakeBV) - got := vsc.generatePolicies(ownerDetails, tc.policyRefs, tc.policies, tc.context, policyOptions{apResources: &appProtectResourcesForVS{}}) - got.BundleValidator = nil - if !cmp.Equal(tc.want, got) { - t.Error(cmp.Diff(tc.want, got)) + res := vsc.generatePolicies(ownerDetails, tc.policyRefs, tc.policies, tc.context, policyOptions{apResources: &appProtectResourcesForVS{}}) + res.BundleValidator = nil + if !cmp.Equal(tc.want, res) { + t.Error(cmp.Diff(tc.want, res)) } }) } @@ -7694,6 +8357,212 @@ func TestGeneratePoliciesFails(t *testing.T) { }, msg: "multi oidc", }, + { + policyRefs: []conf_v1.PolicyReference{ + { + Name: "api-key-policy", + Namespace: "default", + }, + { + Name: "api-key-policy-2", + Namespace: "default", + }, + }, + policies: map[string]*conf_v1.Policy{ + "default/api-key-policy": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "api-key-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + APIKey: &conf_v1.APIKey{ + SuppliedIn: &conf_v1.SuppliedIn{ + Header: []string{"X-API-Key"}, + Query: []string{"api-key"}, + }, + ClientSecret: "api-key-secret", + }, + }, + }, + "default/api-key-policy-2": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "api-key-policy-2", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + APIKey: &conf_v1.APIKey{ + SuppliedIn: &conf_v1.SuppliedIn{ + Header: []string{"X-API-Key"}, + Query: []string{"api-key"}, + }, + ClientSecret: "api-key-secret", + }, + }, + }, + }, + policyOpts: policyOptions{ + secretRefs: map[string]*secrets.SecretReference{ + "default/api-key-secret": { + Secret: &api_v1.Secret{ + Type: secrets.SecretTypeAPIKey, + Data: map[string][]byte{ + "client1": []byte("password"), + }, + }, + }, + }, + }, + expected: policiesCfg{ + ErrorReturn: &version2.Return{ + Code: 500, + }, + }, + expectedWarnings: Warnings{ + nil: { + `Multiple API Key policies in the same context is not valid. API Key policy default/api-key-policy-2 will be ignored`, + }, + }, + expectedOidc: &oidcPolicyCfg{}, + msg: "api key multi api key policies", + }, + { + policyRefs: []conf_v1.PolicyReference{ + { + Name: "api-key-policy", + Namespace: "default", + }, + { + Name: "api-key-policy-2", + Namespace: "default", + }, + }, + policies: map[string]*conf_v1.Policy{ + "default/api-key-policy": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "api-key-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + APIKey: &conf_v1.APIKey{ + SuppliedIn: &conf_v1.SuppliedIn{ + Header: []string{"X-API-Key"}, + Query: []string{"api-key"}, + }, + ClientSecret: "api-key-secret", + }, + }, + }, + "default/api-key-policy-2": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "api-key-policy-2", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + APIKey: &conf_v1.APIKey{ + SuppliedIn: &conf_v1.SuppliedIn{ + Header: []string{"X-API-Key"}, + Query: []string{"api-key"}, + }, + ClientSecret: "api-key-secret", + }, + }, + }, + }, + policyOpts: policyOptions{ + secretRefs: map[string]*secrets.SecretReference{ + "default/api-key-secret": { + Secret: &api_v1.Secret{ + Type: secrets.SecretTypeJWK, + Data: map[string][]byte{ + "client1": []byte("password"), + }, + }, + }, + }, + }, + expected: policiesCfg{ + ErrorReturn: &version2.Return{ + Code: 500, + }, + }, + expectedWarnings: Warnings{ + nil: { + `API Key policy default/api-key-policy references a secret default/api-key-secret of a wrong type 'nginx.org/jwk', must be 'nginx.org/apikey'`, + }, + }, + expectedOidc: &oidcPolicyCfg{}, + msg: "api key referencing wrong secret type", + }, + { + policyRefs: []conf_v1.PolicyReference{ + { + Name: "api-key-policy", + Namespace: "default", + }, + { + Name: "api-key-policy-2", + Namespace: "default", + }, + }, + policies: map[string]*conf_v1.Policy{ + "default/api-key-policy": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "api-key-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + APIKey: &conf_v1.APIKey{ + SuppliedIn: &conf_v1.SuppliedIn{ + Header: []string{"X-API-Key"}, + Query: []string{"api-key"}, + }, + ClientSecret: "api-key-secret", + }, + }, + }, + "default/api-key-policy-2": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "api-key-policy-2", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + APIKey: &conf_v1.APIKey{ + SuppliedIn: &conf_v1.SuppliedIn{ + Header: []string{"X-API-Key"}, + Query: []string{"api-key"}, + }, + ClientSecret: "api-key-secret", + }, + }, + }, + }, + policyOpts: policyOptions{ + secretRefs: map[string]*secrets.SecretReference{ + "default/api-key-secret": { + Secret: &api_v1.Secret{ + Type: secrets.SecretTypeAPIKey, + Data: map[string][]byte{ + "client1": []byte("password"), + "client2": []byte("password"), + }, + }, + Error: errors.New("secret is invalid"), + }, + }, + }, + expected: policiesCfg{ + ErrorReturn: &version2.Return{ + Code: 500, + }, + }, + expectedWarnings: Warnings{ + nil: { + `API Key default/api-key-policy references an invalid secret default/api-key-secret: secret is invalid`, + }, + }, + expectedOidc: &oidcPolicyCfg{}, + msg: "api key referencing invalid api key secrets", + }, { policyRefs: []conf_v1.PolicyReference{ { @@ -7759,31 +8628,33 @@ func TestGeneratePoliciesFails(t *testing.T) { } for _, test := range tests { - vsc := newVirtualServerConfigurator(&ConfigParams{}, false, false, &StaticConfigParams{}, false, &fakeBV) + t.Run(test.msg, func(t *testing.T) { + vsc := newVirtualServerConfigurator(&ConfigParams{}, false, false, &StaticConfigParams{}, false, &fakeBV) - if test.oidcPolCfg != nil { - vsc.oidcPolCfg = test.oidcPolCfg - } + if test.oidcPolCfg != nil { + vsc.oidcPolCfg = test.oidcPolCfg + } - result := vsc.generatePolicies(ownerDetails, test.policyRefs, test.policies, test.context, test.policyOpts) - result.BundleValidator = nil - if diff := cmp.Diff(test.expected, result); diff != "" { - t.Errorf("generatePolicies() '%v' mismatch (-want +got):\n%s", test.msg, diff) - } - if !reflect.DeepEqual(vsc.warnings, test.expectedWarnings) { - t.Errorf( - "generatePolicies() returned warnings of \n%v but expected \n%v for the case of %s", - vsc.warnings, - test.expectedWarnings, - test.msg, - ) - } - if diff := cmp.Diff(test.expectedOidc.oidc, vsc.oidcPolCfg.oidc); diff != "" { - t.Errorf("generatePolicies() '%v' mismatch (-want +got):\n%s", test.msg, diff) - } - if diff := cmp.Diff(test.expectedOidc.key, vsc.oidcPolCfg.key); diff != "" { - t.Errorf("generatePolicies() '%v' mismatch (-want +got):\n%s", test.msg, diff) - } + result := vsc.generatePolicies(ownerDetails, test.policyRefs, test.policies, test.context, test.policyOpts) + result.BundleValidator = nil + if diff := cmp.Diff(test.expected, result); diff != "" { + t.Errorf("generatePolicies() '%v' mismatch (-want +got):\n%s", test.msg, diff) + } + if !reflect.DeepEqual(vsc.warnings, test.expectedWarnings) { + t.Errorf( + "generatePolicies() returned warnings of \n%v but expected \n%v for the case of %s", + vsc.warnings, + test.expectedWarnings, + test.msg, + ) + } + if diff := cmp.Diff(test.expectedOidc.oidc, vsc.oidcPolCfg.oidc); diff != "" { + t.Errorf("generatePolicies() '%v' mismatch (-want +got):\n%s", test.msg, diff) + } + if diff := cmp.Diff(test.expectedOidc.key, vsc.oidcPolCfg.key); diff != "" { + t.Errorf("generatePolicies() '%v' mismatch (-want +got):\n%s", test.msg, diff) + } + }) } } @@ -14669,3 +15540,24 @@ func (*fakeBundleValidator) validate(bundle string) (string, error) { } return bundle, nil } + +func TestRFC1123ToSnake(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "valid", + input: "api-policy-1", + expected: "api_policy_1", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !cmp.Equal(rfc1123ToSnake(tt.input), tt.expected) { + t.Errorf(cmp.Diff(rfc1123ToSnake(tt.input), tt.expected)) + } + }) + } +} diff --git a/internal/k8s/controller.go b/internal/k8s/controller.go index 8c64326569..6d8f49bfb3 100644 --- a/internal/k8s/controller.go +++ b/internal/k8s/controller.go @@ -3244,6 +3244,10 @@ func (lbc *LoadBalancerController) createVirtualServerEx(virtualServer *conf_v1. if err != nil { glog.Warningf("Error getting OIDC secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) } + err = lbc.addAPIKeySecretRefs(virtualServerEx.SecretRefs, policies) + if err != nil { + glog.Warningf("Error getting APIKey secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) + } err = lbc.addWAFPolicyRefs(virtualServerEx.ApPolRefs, virtualServerEx.LogConfRefs, policies) if err != nil { @@ -3367,6 +3371,12 @@ func (lbc *LoadBalancerController) createVirtualServerEx(virtualServer *conf_v1. if err != nil { glog.Warningf("Error getting OIDC secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) } + + err = lbc.addAPIKeySecretRefs(virtualServerEx.SecretRefs, vsRoutePolicies) + if err != nil { + glog.Warningf("Error getting APIKey secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) + } + } for _, vsr := range virtualServerRoutes { @@ -3397,6 +3407,11 @@ func (lbc *LoadBalancerController) createVirtualServerEx(virtualServer *conf_v1. glog.Warningf("Error getting OIDC secrets for VirtualServerRoute %v/%v: %v", vsr.Namespace, vsr.Name, err) } + err = lbc.addAPIKeySecretRefs(virtualServerEx.SecretRefs, vsrSubroutePolicies) + if err != nil { + glog.Warningf("Error getting APIKey secrets for VirtualServerRoute %v/%v: %v", vsr.Namespace, vsr.Name, err) + } + err = lbc.addWAFPolicyRefs(virtualServerEx.ApPolRefs, virtualServerEx.LogConfRefs, vsrSubroutePolicies) if err != nil { glog.Warningf("Error getting WAF policies for VirtualServerRoute %v/%v: %v", vsr.Namespace, vsr.Name, err) @@ -3650,6 +3665,25 @@ func (lbc *LoadBalancerController) addOIDCSecretRefs(secretRefs map[string]*secr return nil } +func (lbc *LoadBalancerController) addAPIKeySecretRefs(secretRefs map[string]*secrets.SecretReference, policies []*conf_v1.Policy) error { + for _, pol := range policies { + if pol.Spec.APIKey == nil { + continue + } + + secretKey := fmt.Sprintf("%v/%v", pol.Namespace, pol.Spec.APIKey.ClientSecret) + secretRef := lbc.secretStore.GetSecret(secretKey) + + secretRefs[secretKey] = secretRef + + if secretRef.Error != nil { + return secretRef.Error + } + + } + return nil +} + // addWAFPolicyRefs ensures the app protect resources that are referenced in policies exist. func (lbc *LoadBalancerController) addWAFPolicyRefs( apPolRef, logConfRef map[string]*unstructured.Unstructured, @@ -3728,6 +3762,8 @@ func findPoliciesForSecret(policies []*conf_v1.Policy, secretNamespace string, s res = append(res, pol) } else if pol.Spec.OIDC != nil && pol.Spec.OIDC.ClientSecret == secretName && pol.Namespace == secretNamespace { res = append(res, pol) + } else if pol.Spec.APIKey != nil && pol.Spec.APIKey.ClientSecret == secretName && pol.Namespace == secretNamespace { + res = append(res, pol) } } diff --git a/internal/k8s/controller_test.go b/internal/k8s/controller_test.go index 50725ea714..988493d337 100644 --- a/internal/k8s/controller_test.go +++ b/internal/k8s/controller_test.go @@ -2090,7 +2090,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`, `basicAuth`, `jwt`, `oidc`, `waf`"), + errors.New("policy default/invalid-policy is invalid: spec: Invalid value: \"\": must specify exactly one of: `accessControl`, `rateLimit`, `ingressMTLS`, `egressMTLS`, `basicAuth`, `apiKey`, `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: )"), diff --git a/internal/k8s/secrets/validation.go b/internal/k8s/secrets/validation.go index 5ca4697148..f98dafebea 100644 --- a/internal/k8s/secrets/validation.go +++ b/internal/k8s/secrets/validation.go @@ -34,6 +34,9 @@ const SecretTypeOIDC api_v1.SecretType = "nginx.org/oidc" //nolint:gosec // G101 // SecretTypeHtpasswd contains an htpasswd file for use in HTTP Basic authorization.. #nosec G101 const SecretTypeHtpasswd api_v1.SecretType = "nginx.org/htpasswd" // #nosec G101 +// SecretTypeAPIKey contains a list of client ID and key for API key authorization.. #nosec G101 +const SecretTypeAPIKey api_v1.SecretType = "nginx.org/apikey" // #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 { @@ -109,6 +112,23 @@ func ValidateOIDCSecret(secret *api_v1.Secret) error { return nil } +// ValidateAPIKeySecret validates the secret. If it is valid, the function returns nil. +func ValidateAPIKeySecret(secret *api_v1.Secret) error { + if secret.Type != SecretTypeAPIKey { + return fmt.Errorf("APIKey secret must be of the type %v", SecretTypeAPIKey) + } + + uniqueKeys := make(map[string]bool) + for _, key := range secret.Data { + if uniqueKeys[string(key)] { + return fmt.Errorf("API Keys cannot be repeated") + } + uniqueKeys[string(key)] = true + } + + 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 { @@ -131,7 +151,8 @@ func IsSupportedSecretType(secretType api_v1.SecretType) bool { secretType == SecretTypeCA || secretType == SecretTypeJWK || secretType == SecretTypeOIDC || - secretType == SecretTypeHtpasswd + secretType == SecretTypeHtpasswd || + secretType == SecretTypeAPIKey } // ValidateSecret validates the secret. If it is valid, the function returns nil. @@ -147,6 +168,8 @@ func ValidateSecret(secret *api_v1.Secret) error { return ValidateOIDCSecret(secret) case SecretTypeHtpasswd: return ValidateHtpasswdSecret(secret) + case SecretTypeAPIKey: + return ValidateAPIKeySecret(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 4539ec4cc6..186d63f5d9 100644 --- a/internal/k8s/secrets/validation_test.go +++ b/internal/k8s/secrets/validation_test.go @@ -65,6 +65,85 @@ func TestValidateJWKSecretFails(t *testing.T) { } } +func TestValidateValidateAPIKeySecret(t *testing.T) { + t.Parallel() + secret := &v1.Secret{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "api-key-secret", + Namespace: "default", + }, + Type: SecretTypeAPIKey, + Data: map[string][]byte{ + "client1": []byte("cGFzc3dvcmQ="), + "client2": []byte("N2ViNDMwOGItY2Q1Yi00NDEzLWI0NTUtYjMyZmQ4OTg2MmZk"), + }, + } + + err := ValidateAPIKeySecret(secret) + if err != nil { + t.Errorf("ValidateAPIKeySecret() returned error %v", err) + } +} + +func TestValidateValidateAPIKeyFails(t *testing.T) { + t.Parallel() + tests := []struct { + secret *v1.Secret + msg string + }{ + { + secret: &v1.Secret{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "api-key-secret", + Namespace: "default", + }, + Type: "some-type", + Data: map[string][]byte{ + "client": nil, + }, + }, + msg: "Incorrect type for API Key secret", + }, + { + secret: &v1.Secret{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "api-key-secret", + Namespace: "default", + }, + Type: SecretTypeAPIKey, + Data: map[string][]byte{ + "client1": []byte("cGFzc3dvcmQ="), + "client2": []byte("N2ViNDMwOGItY2Q1Yi00NDEzLWI0NTUtYjMyZmQ4OTg2MmZk"), + "client3": []byte("N2ViNDMwOGItY2Q1Yi00NDEzLWI0NTUtYjMyZmQ4OTg2MmZk"), + }, + }, + msg: "repeated API Keys for API Key secret", + }, + { + secret: &v1.Secret{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "api-key-secret", + Namespace: "default", + }, + Type: SecretTypeAPIKey, + Data: map[string][]byte{ + "client1": []byte(""), + "client2": []byte(""), + }, + }, + msg: "repeated empty API Keys for API Key secret", + }, + } + + for _, test := range tests { + err := ValidateAPIKeySecret(test.secret) + t.Logf("ValidateAPIKeySecret() returned error %v", err) + if err == nil { + t.Errorf("ValidateAPIKeySecret() returned no error for the case of %s", test.msg) + } + } +} + func TestValidateHtpasswdSecret(t *testing.T) { t.Parallel() secret := &v1.Secret{ @@ -450,6 +529,19 @@ func TestValidateSecret(t *testing.T) { }, msg: "Valid OIDC secret", }, + { + secret: &v1.Secret{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "api-key", + Namespace: "default", + }, + Type: SecretTypeAPIKey, + Data: map[string][]byte{ + "client1": []byte("cGFzc3dvcmQ="), + }, + }, + msg: "Valid API Key secret", + }, } for _, test := range tests { @@ -509,6 +601,20 @@ func TestValidateSecretFails(t *testing.T) { }, msg: "Missing htpasswd for Htpasswd secret", }, + { + secret: &v1.Secret{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "api-key", + Namespace: "default", + }, + Type: SecretTypeAPIKey, + Data: map[string][]byte{ + "client1": []byte("cGFzc3dvcmQ="), + "client2": []byte("cGFzc3dvcmQ="), + }, + }, + msg: "duplicated API Keys in API Key secret", + }, } for _, test := range tests { @@ -545,6 +651,10 @@ func TestHasCorrectSecretType(t *testing.T) { secretType: SecretTypeHtpasswd, expected: true, }, + { + secretType: SecretTypeAPIKey, + expected: true, + }, { secretType: "some-type", expected: false, diff --git a/internal/telemetry/cluster.go b/internal/telemetry/cluster.go index 10ff570cd3..f44bb45375 100644 --- a/internal/telemetry/cluster.go +++ b/internal/telemetry/cluster.go @@ -193,6 +193,8 @@ func (c *Collector) PolicyCount() map[string]int { policyCounters["OIDC"]++ case spec.WAF != nil: policyCounters["WAF"]++ + case spec.APIKey != nil: + policyCounters["APIKey"]++ } } return policyCounters diff --git a/internal/telemetry/collector.go b/internal/telemetry/collector.go index e2507b69ac..4c480670bf 100644 --- a/internal/telemetry/collector.go +++ b/internal/telemetry/collector.go @@ -142,6 +142,7 @@ func (c *Collector) Collect(ctx context.Context) { IngressClasses: int64(report.IngressClassCount), AccessControlPolicies: int64(report.AccessControlCount), RateLimitPolicies: int64(report.RateLimitCount), + APIKeyPolicies: int64(report.APIKeyAuthCount), JWTAuthPolicies: int64(report.JWTAuthCount), BasicAuthPolicies: int64(report.BasicAuthCount), IngressMTLSPolicies: int64(report.IngressMTLSCount), @@ -191,6 +192,7 @@ type Report struct { AccessControlCount int RateLimitCount int JWTAuthCount int + APIKeyAuthCount int BasicAuthCount int IngressMTLSCount int EgressMTLSCount int @@ -261,6 +263,7 @@ func (c *Collector) BuildReport(ctx context.Context) (Report, error) { var ( accessControlCount int rateLimitCount int + apiKeyCount int jwtAuthCount int basicAuthCount int ingressMTLSCount int @@ -273,6 +276,7 @@ func (c *Collector) BuildReport(ctx context.Context) (Report, error) { policies := c.PolicyCount() accessControlCount = policies["AccessControl"] rateLimitCount = policies["RateLimit"] + apiKeyCount = policies["APIKey"] jwtAuthCount = policies["JWTAuth"] basicAuthCount = policies["BasicAuth"] ingressMTLSCount = policies["IngressMTLS"] @@ -318,6 +322,7 @@ func (c *Collector) BuildReport(ctx context.Context) (Report, error) { IngressClassCount: ingressClassCount, AccessControlCount: accessControlCount, RateLimitCount: rateLimitCount, + APIKeyAuthCount: apiKeyCount, JWTAuthCount: jwtAuthCount, BasicAuthCount: basicAuthCount, IngressMTLSCount: ingressMTLSCount, diff --git a/internal/telemetry/data.avdl b/internal/telemetry/data.avdl index c51ba6e2e2..c374841ead 100644 --- a/internal/telemetry/data.avdl +++ b/internal/telemetry/data.avdl @@ -78,6 +78,9 @@ It is the UID of the `kube-system` Namespace. */ /** RateLimitPolicies is the number of RateLimit policies managed by NGINX Ingress Controller */ long? RateLimitPolicies = null; + /** APIKeyPolicies is the number of APIKey policies managed by NGINX Ingress Controller */ + long? APIKeyPolicies = null; + /** JWTAuthPolicies is the number of JWTAuth policies managed by NGINX Ingress Controller */ long? JWTAuthPolicies = null; diff --git a/internal/telemetry/exporter.go b/internal/telemetry/exporter.go index 942940fdb8..aac38756c5 100644 --- a/internal/telemetry/exporter.go +++ b/internal/telemetry/exporter.go @@ -95,6 +95,8 @@ type NICResourceCounts struct { AccessControlPolicies int64 // RateLimitPolicies is the number of RateLimit policies managed by NGINX Ingress Controller RateLimitPolicies int64 + // APIKeyPolicies is the number of APIKey policies managed by NGINX Ingress Controller + APIKeyPolicies int64 // JWTAuthPolicies is the number of JWTAuth policies managed by NGINX Ingress Controller JWTAuthPolicies int64 // BasicAuthPolicies is the number of BasicAuth policies managed by NGINX Ingress Controller diff --git a/internal/telemetry/nicresourcecounts_attributes_generated.go b/internal/telemetry/nicresourcecounts_attributes_generated.go index 21be8dcc3f..f68e171f7c 100644 --- a/internal/telemetry/nicresourcecounts_attributes_generated.go +++ b/internal/telemetry/nicresourcecounts_attributes_generated.go @@ -28,6 +28,7 @@ func (d *NICResourceCounts) Attributes() []attribute.KeyValue { attrs = append(attrs, attribute.Int64("IngressClasses", d.IngressClasses)) attrs = append(attrs, attribute.Int64("AccessControlPolicies", d.AccessControlPolicies)) attrs = append(attrs, attribute.Int64("RateLimitPolicies", d.RateLimitPolicies)) + attrs = append(attrs, attribute.Int64("APIKeyPolicies", d.APIKeyPolicies)) attrs = append(attrs, attribute.Int64("JWTAuthPolicies", d.JWTAuthPolicies)) attrs = append(attrs, attribute.Int64("BasicAuthPolicies", d.BasicAuthPolicies)) attrs = append(attrs, attribute.Int64("IngressMTLSPolicies", d.IngressMTLSPolicies)) diff --git a/pkg/apis/configuration/v1/types.go b/pkg/apis/configuration/v1/types.go index 58558a4d95..d25261d947 100644 --- a/pkg/apis/configuration/v1/types.go +++ b/pkg/apis/configuration/v1/types.go @@ -577,6 +577,7 @@ type PolicySpec struct { EgressMTLS *EgressMTLS `json:"egressMTLS"` OIDC *OIDC `json:"oidc"` WAF *WAF `json:"waf"` + APIKey *APIKey `json:"apiKey"` } // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object @@ -676,3 +677,15 @@ type SecurityLog struct { ApLogBundle string `json:"apLogBundle"` LogDest string `json:"logDest"` } + +// APIKey defines an API Key policy. +type APIKey struct { + SuppliedIn *SuppliedIn `json:"suppliedIn"` + ClientSecret string `json:"clientSecret"` +} + +// SuppliedIn defines the locations API Key should be supplied in. +type SuppliedIn struct { + Header []string `json:"header"` + Query []string `json:"query"` +} diff --git a/pkg/apis/configuration/v1/zz_generated.deepcopy.go b/pkg/apis/configuration/v1/zz_generated.deepcopy.go index 52c5327146..b617f3cb93 100644 --- a/pkg/apis/configuration/v1/zz_generated.deepcopy.go +++ b/pkg/apis/configuration/v1/zz_generated.deepcopy.go @@ -9,6 +9,27 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIKey) DeepCopyInto(out *APIKey) { + *out = *in + if in.SuppliedIn != nil { + in, out := &in.SuppliedIn, &out.SuppliedIn + *out = new(SuppliedIn) + (*in).DeepCopyInto(*out) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIKey. +func (in *APIKey) DeepCopy() *APIKey { + if in == nil { + return nil + } + out := new(APIKey) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AccessControl) DeepCopyInto(out *AccessControl) { *out = *in @@ -689,6 +710,11 @@ func (in *PolicySpec) DeepCopyInto(out *PolicySpec) { *out = new(WAF) (*in).DeepCopyInto(*out) } + if in.APIKey != nil { + in, out := &in.APIKey, &out.APIKey + *out = new(APIKey) + (*in).DeepCopyInto(*out) + } return } @@ -973,6 +999,32 @@ func (in *Split) DeepCopy() *Split { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SuppliedIn) DeepCopyInto(out *SuppliedIn) { + *out = *in + if in.Header != nil { + in, out := &in.Header, &out.Header + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Query != nil { + in, out := &in.Query, &out.Query + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SuppliedIn. +func (in *SuppliedIn) DeepCopy() *SuppliedIn { + if in == nil { + return nil + } + out := new(SuppliedIn) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TLS) DeepCopyInto(out *TLS) { *out = *in diff --git a/pkg/apis/configuration/validation/policy.go b/pkg/apis/configuration/validation/policy.go index a762146446..5483cd462a 100644 --- a/pkg/apis/configuration/validation/policy.go +++ b/pkg/apis/configuration/validation/policy.go @@ -72,6 +72,11 @@ func validatePolicySpec(spec *v1.PolicySpec, fieldPath *field.Path, isPlus, enab fieldCount++ } + if spec.APIKey != nil { + allErrs = append(allErrs, validateAPIKey(spec.APIKey, fieldPath.Child("apiKey"))...) + fieldCount++ + } + if spec.WAF != nil { if !isPlus { allErrs = append(allErrs, field.Forbidden(fieldPath.Child("waf"), "WAF is only supported in NGINX Plus")) @@ -86,7 +91,7 @@ func validatePolicySpec(spec *v1.PolicySpec, fieldPath *field.Path, isPlus, enab } if fieldCount != 1 { - msg := "must specify exactly one of: `accessControl`, `rateLimit`, `ingressMTLS`, `egressMTLS`, `basicAuth`" + msg := "must specify exactly one of: `accessControl`, `rateLimit`, `ingressMTLS`, `egressMTLS`, `basicAuth`, `apiKey`" if isPlus { msg = fmt.Sprint(msg, ", `jwt`, `oidc`, `waf`") } @@ -277,6 +282,38 @@ func validateOIDC(oidc *v1.OIDC, fieldPath *field.Path) field.ErrorList { return append(allErrs, validateClientID(oidc.ClientID, fieldPath.Child("clientID"))...) } +func validateAPIKey(apiKey *v1.APIKey, fieldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + if apiKey.SuppliedIn.Query == nil && apiKey.SuppliedIn.Header == nil { + msg := "at least one query or header name must be provided" + allErrs = append(allErrs, field.Required(fieldPath.Child("SuppliedIn"), msg)) + } + + if apiKey.SuppliedIn.Header != nil { + for _, header := range apiKey.SuppliedIn.Header { + for _, msg := range validation.IsHTTPHeaderName(header) { + allErrs = append(allErrs, field.Invalid(fieldPath.Child("suppliedIn.header"), header, msg)) + } + } + } + + if apiKey.SuppliedIn.Query != nil { + for _, query := range apiKey.SuppliedIn.Query { + if err := ValidateEscapedString(query); err != nil { + allErrs = append(allErrs, field.Invalid(fieldPath.Child("suppliedIn.query"), query, err.Error())) + } + } + } + + if apiKey.ClientSecret == "" { + allErrs = append(allErrs, field.Required(fieldPath.Child("clientSecret"), "")) + } + + allErrs = append(allErrs, validateSecretName(apiKey.ClientSecret, fieldPath.Child("clientSecret"))...) + + return allErrs +} + func validateWAF(waf *v1.WAF, fieldPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} bundleMode := waf.ApBundle != "" diff --git a/pkg/apis/configuration/validation/policy_test.go b/pkg/apis/configuration/validation/policy_test.go index 36a4736d71..6a66806b54 100644 --- a/pkg/apis/configuration/validation/policy_test.go +++ b/pkg/apis/configuration/validation/policy_test.go @@ -1303,6 +1303,98 @@ func TestValidateOIDC_PassesOnValidOIDC(t *testing.T) { } } +func TestValidateAPIKeyPolicy_PassOnValidInput(t *testing.T) { + t.Parallel() + tests := []struct { + apiKey *v1.APIKey + msg string + }{ + { + apiKey: &v1.APIKey{ + SuppliedIn: &v1.SuppliedIn{ + Header: []string{ + "X-API-Key", + }, + }, + ClientSecret: "secret", + }, + }, + } + + for _, test := range tests { + allErrs := validateAPIKey(test.apiKey, field.NewPath("apiKey")) + if len(allErrs) != 0 { + t.Errorf("validateAPIKey() returned errors %v for valid input for the case of %v", allErrs, test.msg) + } + } +} + +func TestValidateAPIKeyPolicy_FailsOnInvalidInput(t *testing.T) { + t.Parallel() + tests := []struct { + apiKey *v1.APIKey + msg string + }{ + { + apiKey: &v1.APIKey{ + SuppliedIn: &v1.SuppliedIn{ + Query: []string{ + "api_key", + }, + }, + }, + msg: "missing secret", + }, + { + apiKey: &v1.APIKey{ + SuppliedIn: &v1.SuppliedIn{}, + ClientSecret: "secret", + }, + msg: "both header and query are missing", + }, + { + apiKey: &v1.APIKey{ + SuppliedIn: &v1.SuppliedIn{ + Header: []string{ + `api.key"`, + }, + }, + ClientSecret: "secret", + }, + msg: "invalid header", + }, + { + apiKey: &v1.APIKey{ + SuppliedIn: &v1.SuppliedIn{ + Query: []string{ + `api_key\`, + }, + }, + ClientSecret: "secret", + }, + msg: "invalid query", + }, + { + apiKey: &v1.APIKey{ + SuppliedIn: &v1.SuppliedIn{ + Query: []string{ + `api_key`, + }, + }, + ClientSecret: "secret_1", + }, + msg: "invalid secret name", + }, + } + + for _, test := range tests { + allErrs := validateAPIKey(test.apiKey, field.NewPath("apiKey")) + if len(allErrs) == 0 { + t.Errorf("validateAPIKey() returned no errors for invalid input for the case of %v", test.msg) + } + } +} + func TestValidateOIDCScope_ErrorsOnInvalidInput(t *testing.T) { t.Parallel() diff --git a/tests/data/apikey-auth-policy/policies/apikey-policy-server.yaml b/tests/data/apikey-auth-policy/policies/apikey-policy-server.yaml new file mode 100644 index 0000000000..db46e29bd4 --- /dev/null +++ b/tests/data/apikey-auth-policy/policies/apikey-policy-server.yaml @@ -0,0 +1,15 @@ +apiVersion: k8s.nginx.org/v1 +kind: Policy +metadata: + name: api-key-policy-server +spec: + apiKey: + suppliedIn: + header: + - "header-server-1" + - "header-server-2" + - "header-server-3" + query: + - "queryServer1" + - "queryServer2" + clientSecret: api-key-client-secret-server diff --git a/tests/data/apikey-auth-policy/policies/apikey-policy-valid-2.yaml b/tests/data/apikey-auth-policy/policies/apikey-policy-valid-2.yaml new file mode 100644 index 0000000000..7c4da9bae0 --- /dev/null +++ b/tests/data/apikey-auth-policy/policies/apikey-policy-valid-2.yaml @@ -0,0 +1,15 @@ +apiVersion: k8s.nginx.org/v1 +kind: Policy +metadata: + name: api-key-policy-2 +spec: + apiKey: + suppliedIn: + header: + - "this-is-another-header" + - "and-other-one" + - "some-other-header" + query: + - "query1" + - "query2" + clientSecret: api-key-client-secret-2 diff --git a/tests/data/apikey-auth-policy/policies/apikey-policy-valid.yaml b/tests/data/apikey-auth-policy/policies/apikey-policy-valid.yaml new file mode 100644 index 0000000000..30c45bbd3b --- /dev/null +++ b/tests/data/apikey-auth-policy/policies/apikey-policy-valid.yaml @@ -0,0 +1,14 @@ +apiVersion: k8s.nginx.org/v1 +kind: Policy +metadata: + name: api-key-policy +spec: + apiKey: + suppliedIn: + header: + - "X-header-name" + - "apikey" + - "some-other-header" + query: + - "queryName" + clientSecret: api-key-client-secret-1 diff --git a/tests/data/apikey-auth-policy/policies/apikey-policy-vs-route.yaml b/tests/data/apikey-auth-policy/policies/apikey-policy-vs-route.yaml new file mode 100644 index 0000000000..99b35026be --- /dev/null +++ b/tests/data/apikey-auth-policy/policies/apikey-policy-vs-route.yaml @@ -0,0 +1,15 @@ +apiVersion: k8s.nginx.org/v1 +kind: Policy +metadata: + name: api-key-policy-vs-route +spec: + apiKey: + suppliedIn: + header: + - "header-route-1" + - "header-route-2" + - "header-route-3" + query: + - "queryRoute1" + - "queryRoute2" + clientSecret: api-key-client-secret-route diff --git a/tests/data/apikey-auth-policy/secret/apikey-secret-1.yaml b/tests/data/apikey-auth-policy/secret/apikey-secret-1.yaml new file mode 100644 index 0000000000..1096e53bbc --- /dev/null +++ b/tests/data/apikey-auth-policy/secret/apikey-secret-1.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: api-key-client-secret-1 +type: nginx.org/apikey +data: + client1: cGFzc3dvcmQ= # password + client2: cGFzc3dvcmQy # password2 diff --git a/tests/data/apikey-auth-policy/secret/apikey-secret-2.yaml b/tests/data/apikey-auth-policy/secret/apikey-secret-2.yaml new file mode 100644 index 0000000000..80003c22b5 --- /dev/null +++ b/tests/data/apikey-auth-policy/secret/apikey-secret-2.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: api-key-client-secret-2 +type: nginx.org/apikey +data: + client3: cGFzc3dvcmQz # password3 + client4: cGFzc3dvcmQ0 # password4 diff --git a/tests/data/apikey-auth-policy/secret/apikey-secret-route.yaml b/tests/data/apikey-auth-policy/secret/apikey-secret-route.yaml new file mode 100644 index 0000000000..436897147f --- /dev/null +++ b/tests/data/apikey-auth-policy/secret/apikey-secret-route.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: api-key-client-secret-route +type: nginx.org/apikey +data: + client1: cGFzc3dvcmQ3 # password7 + client2: cGFzc3dvcmQ4 # password8 diff --git a/tests/data/apikey-auth-policy/secret/apikey-secret-server.yaml b/tests/data/apikey-auth-policy/secret/apikey-secret-server.yaml new file mode 100644 index 0000000000..e3097c622c --- /dev/null +++ b/tests/data/apikey-auth-policy/secret/apikey-secret-server.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: api-key-client-secret-server +type: nginx.org/apikey +data: + client1: cGFzc3dvcmQ1 # password5 + client2: cGFzc3dvcmQ2 # password6 diff --git a/tests/data/apikey-auth-policy/spec/virtual-server-policy-single.yaml b/tests/data/apikey-auth-policy/spec/virtual-server-policy-single.yaml new file mode 100644 index 0000000000..682caf4179 --- /dev/null +++ b/tests/data/apikey-auth-policy/spec/virtual-server-policy-single.yaml @@ -0,0 +1,29 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: virtual-server +spec: + host: virtual-server.example.com + policies: + - name: api-key-policy + 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 + policies: + - name: api-key-policy-2 + - path: /no-auth + action: + pass: backend2 + location-snippets: + auth_request off; diff --git a/tests/data/apikey-auth-policy/spec/vsr/backend1-vsr.yaml b/tests/data/apikey-auth-policy/spec/vsr/backend1-vsr.yaml new file mode 100644 index 0000000000..0c18d8ad93 --- /dev/null +++ b/tests/data/apikey-auth-policy/spec/vsr/backend1-vsr.yaml @@ -0,0 +1,14 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServerRoute +metadata: + name: backend1 +spec: + host: virtual-server.example.com + upstreams: + - name: backend1 + service: backend1-svc + port: 80 + subroutes: + - path: /backend1 + action: + pass: backend1 diff --git a/tests/data/apikey-auth-policy/spec/vsr/backend2-vsr.yaml b/tests/data/apikey-auth-policy/spec/vsr/backend2-vsr.yaml new file mode 100644 index 0000000000..1067a51ebb --- /dev/null +++ b/tests/data/apikey-auth-policy/spec/vsr/backend2-vsr.yaml @@ -0,0 +1,16 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServerRoute +metadata: + name: backend2 +spec: + host: virtual-server.example.com + upstreams: + - name: backend2 + service: backend2-svc + port: 80 + subroutes: + - path: /backend2 + action: + pass: backend2 + policies: + - name: api-key-policy-vs-route diff --git a/tests/data/apikey-auth-policy/spec/vsr/virtual-server-with-vsr.yaml b/tests/data/apikey-auth-policy/spec/vsr/virtual-server-with-vsr.yaml new file mode 100644 index 0000000000..75d1d9f41a --- /dev/null +++ b/tests/data/apikey-auth-policy/spec/vsr/virtual-server-with-vsr.yaml @@ -0,0 +1,22 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: virtual-server +spec: + host: virtual-server.example.com + policies: + - name: api-key-policy-server + upstreams: + - name: backend1 + service: backend1-svc + port: 80 + routes: + - path: /backend1 + route: backend1 + - path: /backend2 + route: backend2 + - path: /no-auth + location-snippets: + auth_request off; + action: + pass: backend1 diff --git a/tests/suite/test_apikey_auth_policies.py b/tests/suite/test_apikey_auth_policies.py new file mode 100644 index 0000000000..cd84e56634 --- /dev/null +++ b/tests/suite/test_apikey_auth_policies.py @@ -0,0 +1,458 @@ +from collections import namedtuple + +import pytest +import requests +from settings import TEST_DATA +from suite.utils.policy_resources_utils import create_policy_from_yaml, delete_policy +from suite.utils.resources_utils import ( + create_secret_from_yaml, + delete_items_from_yaml, + delete_secret, + get_apikey_auth_secrets_from_yaml, + get_apikey_policy_details_from_yaml, + wait_before_test, + wait_until_all_pods_are_ready, +) +from suite.utils.vs_vsr_resources_utils import create_v_s_route_from_yaml, delete_and_create_vs_from_yaml + +apikey_auth_pol_valid = f"{TEST_DATA}/apikey-auth-policy/policies/apikey-policy-valid.yaml" +apikey_auth_pol_valid_2 = f"{TEST_DATA}/apikey-auth-policy/policies/apikey-policy-valid-2.yaml" +apikey_auth_pol_server = f"{TEST_DATA}/apikey-auth-policy/policies/apikey-policy-server.yaml" +apikey_auth_pol_route = f"{TEST_DATA}/apikey-auth-policy/policies/apikey-policy-vs-route.yaml" + +apikey_auth_secret_1 = f"{TEST_DATA}/apikey-auth-policy/secret/apikey-secret-1.yaml" +apikey_auth_secret_2 = f"{TEST_DATA}/apikey-auth-policy/secret/apikey-secret-2.yaml" +apikey_auth_secret_server = f"{TEST_DATA}/apikey-auth-policy/secret/apikey-secret-server.yaml" +apikey_auth_secret_route = f"{TEST_DATA}/apikey-auth-policy/secret/apikey-secret-route.yaml" + +apikey_auth_vs_single_src = f"{TEST_DATA}/apikey-auth-policy/spec/virtual-server-policy-single.yaml" +apikey_auth_vs_vsr_src = f"{TEST_DATA}/apikey-auth-policy/spec/vsr/virtual-server-with-vsr.yaml" + +vsr_1_src = f"{TEST_DATA}/apikey-auth-policy/spec/vsr/backend1-vsr.yaml" +vsr_2_src = f"{TEST_DATA}/apikey-auth-policy/spec/vsr/backend2-vsr.yaml" + + +std_vs_src = f"{TEST_DATA}/virtual-server/standard/virtual-server.yaml" + + +@pytest.mark.policies +@pytest.mark.parametrize( + "crd_ingress_controller, virtual_server_setup", + [ + ( + { + "type": "complete", + "extra_args": [ + f"-enable-custom-resources", + f"-enable-leader-election=false", + f"-enable-snippets", + ], + }, + { + "example": "virtual-server", + "app_type": "simple", + }, + ) + ], + indirect=True, +) +class TestAPIKeyAuthPolicies: + def setup_single_policy( + self, kube_apis, test_namespace: str, secret_src: str, policy_src: str, vs_host: str + ) -> namedtuple: + APIKey_policy_details = namedtuple( + "APIKey_policy_details", ["headers", "queries", "policy_name", "secret_name", "vs_host", "apikeys"] + ) + print(f"Create apikey auth secret") + secret_name = create_secret_from_yaml(kube_apis.v1, test_namespace, secret_src) + apikeys = get_apikey_auth_secrets_from_yaml(secret_src) + details = get_apikey_policy_details_from_yaml(policy_src) + + print(f"Create apikey auth policy") + policy_name = create_policy_from_yaml(kube_apis.custom_objects, policy_src, test_namespace) + wait_before_test() + + headers = details["headers"] + queries = details["queries"] + return APIKey_policy_details( + headers=headers, + queries=queries, + policy_name=policy_name, + secret_name=secret_name, + vs_host=vs_host, + apikeys=apikeys, + ) + + def test_apikey_auth_policy_vs(self, kube_apis, crd_ingress_controller, virtual_server_setup, test_namespace): + apikey_policy_details = self.setup_single_policy( + kube_apis, + virtual_server_setup.namespace, + apikey_auth_secret_1, + apikey_auth_pol_valid, + virtual_server_setup.vs_host, + ) + + apikey_policy_2_details = self.setup_single_policy( + kube_apis, + virtual_server_setup.namespace, + apikey_auth_secret_2, + apikey_auth_pol_valid_2, + virtual_server_setup.vs_host, + ) + + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + apikey_auth_vs_single_src, + virtual_server_setup.namespace, + ) + + host = apikey_policy_details.vs_host + + wait_until_all_pods_are_ready(kube_apis.v1, test_namespace) + wait_before_test() + + # /no-auth path + no_auth_headers = {"host": host} + no_auth_path = ( + f"http://{virtual_server_setup.public_endpoint.public_ip}" + f":{virtual_server_setup.public_endpoint.port}/no-auth" + ) + no_auth_resp = requests.get(no_auth_path, headers=no_auth_headers) + + # /backend1 path (uses policy on the server level) + # without auth headers + backend1_without_auth_headers = { + "host": host, + } + backend1_without_auth_resp = requests.get( + virtual_server_setup.backend_1_url, headers=backend1_without_auth_headers + ) + # with wrong password in header + backend1_correct_header_with_wrong_password_resps = [] + for header in apikey_policy_details.headers: + backend1_with_auth_headers_but_wrong_password = {"host": host, header: "wrongpassword"} + backend1_wrong_password_resp = requests.get( + virtual_server_setup.backend_1_url, headers=backend1_with_auth_headers_but_wrong_password + ) + backend1_correct_header_with_wrong_password_resps.append(backend1_wrong_password_resp) + # with wrong password in query + backend1_correct_query_with_wrong_password_resps = [] + for query in apikey_policy_details.queries: + host_header = {"host": host} + params = {query: "wrongpassword"} + backend1_wrong_password_resp = requests.get( + virtual_server_setup.backend_1_url, headers=host_header, params=params + ) + backend1_correct_query_with_wrong_password_resps.append(backend1_wrong_password_resp) + # try each header with each correct apikey + backend1_correct_header_with_correct_password_resps = [] + for header in apikey_policy_details.headers: + for key in apikey_policy_details.apikeys: + backend1_with_auth_headers_correct_password = {"host": host, header: key} + backend1_correct_password_resp = requests.get( + virtual_server_setup.backend_1_url, headers=backend1_with_auth_headers_correct_password + ) + backend1_correct_header_with_correct_password_resps.append(backend1_correct_password_resp) + # try each query with each correct apikey + backend1_correct_query_with_correct_password_resps = [] + for query in apikey_policy_details.queries: + for key in apikey_policy_details.apikeys: + params = {query: key} + host_header = {"host": host} + backend1_correct_password_resp = requests.get( + virtual_server_setup.backend_1_url, headers=host_header, params=params + ) + backend1_correct_query_with_correct_password_resps.append(backend1_correct_password_resp) + + # /backend2 path (uses policy on the route level) + # without auth headers + backend2_without_auth_headers = {"host": host} + backend2_without_auth_resp = requests.get( + virtual_server_setup.backend_2_url, headers=backend2_without_auth_headers + ) + # with wrong password in header + backend2_correct_header_with_wrong_password_resps = [] + for header in apikey_policy_2_details.headers: + backend2_with_auth_headers_but_wrong_password = {"host": host, header: "wrongpassword"} + backend2_wrong_password_resp = requests.get( + virtual_server_setup.backend_2_url, headers=backend2_with_auth_headers_but_wrong_password + ) + backend2_correct_header_with_wrong_password_resps.append(backend2_wrong_password_resp) + # with wrong password in query + backend2_correct_query_with_wrong_password_resps = [] + for query in apikey_policy_2_details.queries: + host_header = {"host": host} + params = {query: "wrongpassword"} + backend2_wrong_password_resp = requests.get( + virtual_server_setup.backend_2_url, headers=host_header, params=params + ) + backend2_correct_query_with_wrong_password_resps.append(backend2_wrong_password_resp) + # try each header with each correct apikey + backend2_correct_header_with_correct_password_resps = [] + for header in apikey_policy_2_details.headers: + for key in apikey_policy_2_details.apikeys: + backend2_with_auth_headers_correct_password = {"host": host, header: key} + backend2_correct_password_resp = requests.get( + virtual_server_setup.backend_2_url, headers=backend2_with_auth_headers_correct_password + ) + backend2_correct_header_with_correct_password_resps.append(backend2_correct_password_resp) + # try each query with each correct apikey + backend2_correct_query_with_correct_password_resps = [] + for query in apikey_policy_2_details.queries: + for key in apikey_policy_2_details.apikeys: + params = {query: key} + host_header = {"host": host} + backend2_correct_password_resp = requests.get( + virtual_server_setup.backend_2_url, headers=host_header, params=params + ) + backend2_correct_query_with_correct_password_resps.append(backend2_correct_password_resp) + + delete_policy(kube_apis.custom_objects, apikey_policy_details.policy_name, test_namespace) + delete_secret(kube_apis.v1, apikey_policy_details.secret_name, test_namespace) + + delete_policy(kube_apis.custom_objects, apikey_policy_2_details.policy_name, test_namespace) + delete_secret(kube_apis.v1, apikey_policy_2_details.secret_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, + ) + + # /no-auth (snippet to turn off auth_request on this route) + assert no_auth_resp.status_code == 200 + + # /backend1 (policy on server level) + assert backend1_without_auth_resp.status_code == 401 + + # with wrong password in header + assert len(backend1_correct_header_with_wrong_password_resps) > 0 + for response in backend1_correct_header_with_wrong_password_resps: + assert response.status_code == 403 + + # with wrong password in query + assert len(backend1_correct_query_with_wrong_password_resps) > 0 + for response in backend1_correct_query_with_wrong_password_resps: + assert response.status_code == 403 + + # with correct password in header + assert len(backend1_correct_header_with_correct_password_resps) > 0 + for response in backend1_correct_header_with_correct_password_resps: + assert response.status_code == 200 + + # with correct password in query + assert len(backend1_correct_query_with_correct_password_resps) > 0 + for response in backend1_correct_query_with_correct_password_resps: + assert response.status_code == 200 + + # /backend2 (policy on route level) + assert backend2_without_auth_resp.status_code == 401 + + # with wrong password in header + assert len(backend2_correct_header_with_wrong_password_resps) > 0 + for response in backend2_correct_header_with_wrong_password_resps: + assert response.status_code == 403 + + # with wrong password in query + assert len(backend2_correct_query_with_wrong_password_resps) > 0 + for response in backend2_correct_query_with_wrong_password_resps: + assert response.status_code == 403 + + # with correct password in header + assert len(backend2_correct_header_with_correct_password_resps) > 0 + for response in backend2_correct_header_with_correct_password_resps: + assert response.status_code == 200 + + # with correct password in query + assert len(backend2_correct_query_with_correct_password_resps) > 0 + for response in backend2_correct_query_with_correct_password_resps: + assert response.status_code == 200 + + def test_apikey_auth_policy_vs_and_vsr( + self, kube_apis, crd_ingress_controller, virtual_server_setup, test_namespace + ): + apikey_policy_details_server = self.setup_single_policy( + kube_apis, + virtual_server_setup.namespace, + apikey_auth_secret_server, + apikey_auth_pol_server, + virtual_server_setup.vs_host, + ) + + apikey_policy_details_route = self.setup_single_policy( + kube_apis, + virtual_server_setup.namespace, + apikey_auth_secret_route, + apikey_auth_pol_route, + virtual_server_setup.vs_host, + ) + + delete_and_create_vs_from_yaml( + kube_apis.custom_objects, + virtual_server_setup.vs_name, + apikey_auth_vs_vsr_src, + virtual_server_setup.namespace, + ) + create_v_s_route_from_yaml(kube_apis.custom_objects, vsr_1_src, virtual_server_setup.namespace) + create_v_s_route_from_yaml(kube_apis.custom_objects, vsr_2_src, virtual_server_setup.namespace) + + host = virtual_server_setup.vs_host + wait_until_all_pods_are_ready(kube_apis.v1, test_namespace) + wait_before_test(5) + + # /no-auth path + no_auth_path_server = ( + f"http://{virtual_server_setup.public_endpoint.public_ip}" + f":{virtual_server_setup.public_endpoint.port}/no-auth" + ) + + no_auth_headers = { + "host": host, + } + no_auth_server_resp = requests.get(no_auth_path_server, headers=no_auth_headers) + + # /backend1 (no policy on this vsr route so uses server level policy) + backend1_path = ( + f"http://{virtual_server_setup.public_endpoint.public_ip}" + f":{virtual_server_setup.public_endpoint.port}/backend1" + ) + backend1_without_auth_headers = {"host": host} + backend1_without_auth_resp = requests.get(backend1_path, headers=backend1_without_auth_headers) + + # with wrong password in header + backend1_correct_header_with_wrong_password_resps = [] + for header in apikey_policy_details_server.headers: + backend1_with_auth_headers = {"host": host, header: "wrongpassword"} + backend1_wrong_password = requests.get(backend1_path, headers=backend1_with_auth_headers) + backend1_correct_header_with_wrong_password_resps.append(backend1_wrong_password) + # with wrong password in query + backend1_correct_query_with_wrong_password_resps = [] + for query in apikey_policy_details_server.queries: + host_header = {"host": host} + params = {query: "wrongpassword"} + backend1_wrong_password_resp = requests.get(backend1_path, headers=host_header, params=params) + backend1_correct_query_with_wrong_password_resps.append(backend1_wrong_password_resp) + # try each header with each correct apikey + backend1_correct_header_with_correct_password_resps = [] + for header in apikey_policy_details_server.headers: + for key in apikey_policy_details_server.apikeys: + backend1_with_auth_headers_correct_password = {"host": host, header: key} + backend1_correct_password_resp = requests.get( + backend1_path, headers=backend1_with_auth_headers_correct_password + ) + backend1_correct_header_with_correct_password_resps.append(backend1_correct_password_resp) + # try each query with each correct apikey + backend1_correct_query_with_correct_password_resps = [] + for query in apikey_policy_details_server.queries: + for key in apikey_policy_details_server.apikeys: + params = {query: key} + host_header = {"host": host} + backend1_correct_password_resp = requests.get(backend1_path, headers=host_header, params=params) + backend1_correct_query_with_correct_password_resps.append(backend1_correct_password_resp) + + # /backend2 path (uses policy on the route level) + backend2_path = ( + f"http://{virtual_server_setup.public_endpoint.public_ip}" + f":{virtual_server_setup.public_endpoint.port}/backend2" + ) + # without auth headers + backend2_without_auth_headers = {"host": host} + backend2_without_auth_resp = requests.get(backend2_path, headers=backend2_without_auth_headers) + # with wrong password in header + backend2_correct_header_with_wrong_password_resps = [] + for header in apikey_policy_details_route.headers: + backend2_with_auth_headers_but_wrong_password = {"host": host, header: "wrongpassword"} + backend2_wrong_password_resp = requests.get( + backend2_path, headers=backend2_with_auth_headers_but_wrong_password + ) + backend2_correct_header_with_wrong_password_resps.append(backend2_wrong_password_resp) + # with wrong password in query + backend2_correct_query_with_wrong_password_resps = [] + for query in apikey_policy_details_route.queries: + host_header = {"host": host} + params = {query: "wrongpassword"} + backend2_wrong_password_resp = requests.get(backend2_path, headers=host_header, params=params) + backend2_correct_query_with_wrong_password_resps.append(backend2_wrong_password_resp) + # try each header with each correct apikey + backend2_correct_header_with_correct_password_resps = [] + for header in apikey_policy_details_route.headers: + for key in apikey_policy_details_route.apikeys: + backend2_with_auth_headers_correct_password = {"host": host, header: key} + backend2_correct_password_resp = requests.get( + backend2_path, headers=backend2_with_auth_headers_correct_password + ) + backend2_correct_header_with_correct_password_resps.append(backend2_correct_password_resp) + # try each query with each correct apikey + backend2_correct_query_with_correct_password_resps = [] + for query in apikey_policy_details_route.queries: + for key in apikey_policy_details_route.apikeys: + params = {query: key} + host_header = {"host": host} + backend2_correct_password_resp = requests.get(backend2_path, headers=host_header, params=params) + backend2_correct_query_with_correct_password_resps.append(backend2_correct_password_resp) + + delete_items_from_yaml(kube_apis.custom_objects, vsr_1_src, virtual_server_setup.namespace) + delete_items_from_yaml(kube_apis.custom_objects, vsr_2_src, virtual_server_setup.namespace) + + delete_policy(kube_apis.custom_objects, apikey_policy_details_server.policy_name, test_namespace) + delete_secret(kube_apis.v1, apikey_policy_details_server.secret_name, test_namespace) + + delete_policy(kube_apis.custom_objects, apikey_policy_details_route.policy_name, test_namespace) + delete_secret(kube_apis.v1, apikey_policy_details_route.secret_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, + ) + + # /no-auth (snippet to turn off auth_request on this route) + assert no_auth_server_resp.status_code == 200 + + # /backend1 (policy on server level) + assert backend1_without_auth_resp.status_code == 401 + + # with wrong password in header + assert len(backend1_correct_header_with_wrong_password_resps) > 0 + for response in backend1_correct_header_with_wrong_password_resps: + assert response.status_code == 403 + + assert len(backend1_correct_query_with_wrong_password_resps) > 0 + for response in backend1_correct_query_with_wrong_password_resps: + assert response.status_code == 403 + + # with correct password in header + assert len(backend1_correct_header_with_correct_password_resps) > 0 + for response in backend1_correct_header_with_correct_password_resps: + assert response.status_code == 200 + + # with correct password in query + assert len(backend1_correct_query_with_correct_password_resps) > 0 + for response in backend1_correct_query_with_correct_password_resps: + assert response.status_code == 200 + + # /backend2 (policy on route level) + assert backend2_without_auth_resp.status_code == 401 + + # with wrong password in header + assert len(backend2_correct_header_with_wrong_password_resps) > 0 + for response in backend2_correct_header_with_wrong_password_resps: + assert response.status_code == 403 + + # with wrong password in query + assert len(backend2_correct_query_with_wrong_password_resps) > 0 + for response in backend2_correct_query_with_wrong_password_resps: + assert response.status_code == 403 + + # with correct password in header + assert len(backend2_correct_header_with_correct_password_resps) > 0 + for response in backend2_correct_header_with_correct_password_resps: + assert response.status_code == 200 + + # with correct password in query + assert len(backend2_correct_query_with_correct_password_resps) > 0 + for response in backend2_correct_query_with_correct_password_resps: + assert response.status_code == 200 diff --git a/tests/suite/utils/resources_utils.py b/tests/suite/utils/resources_utils.py index 053f3e3e92..7bf82e6e46 100644 --- a/tests/suite/utils/resources_utils.py +++ b/tests/suite/utils/resources_utils.py @@ -1,5 +1,6 @@ """Describe methods to utilize the kubernetes-client.""" +import base64 import json import os import re @@ -1748,3 +1749,42 @@ def get_resource_metrics(kube_apis, plural, namespace="nginx-ingress") -> str: else: return "Invalid plural specified. Please use 'pods' or 'nodes' as the plural" return metrics["items"] + + +def get_apikey_auth_secrets_from_yaml(yaml_manifest) -> list: + """ + Get apikey auth keys from yaml file. + + :param yaml_manifest: an absolute path to file + :return: []apikeys + """ + api_keys = [] + + with open(yaml_manifest) as file: + data = yaml.safe_load(file) + if "data" in data: + for key, encoded_value in data["data"].items(): + decoded_value = base64.b64decode(encoded_value).decode("utf-8") + api_keys.append(decoded_value) + return api_keys + + +def get_apikey_policy_details_from_yaml(yaml_manifest) -> dict: + """ + Extract headers and queries from an API key policy yaml file. + + :param yaml_manifest: an absolute path to file + :return: dictionary with 'headers' and 'queries' + """ + details = {"headers": [], "queries": []} + + with open(yaml_manifest) as file: + data = yaml.safe_load(file) + + if "spec" in data and "apiKey" in data["spec"] and "suppliedIn" in data["spec"]["apiKey"]: + if "header" in data["spec"]["apiKey"]["suppliedIn"]: + details["headers"] = data["spec"]["apiKey"]["suppliedIn"]["header"] + if "query" in data["spec"]["apiKey"]["suppliedIn"]: + details["queries"] = data["spec"]["apiKey"]["suppliedIn"]["query"] + + return details