This is an automated email from the ASF dual-hosted git repository. smolnar82 pushed a commit to branch knox_idf in repository https://gitbox.apache.org/repos/asf/knox.git
commit 5041af985d35497c03444c41d5184c1414931822 Author: Sandor Molnar <[email protected]> AuthorDate: Sun Jun 21 10:04:13 2026 +0200 KNOX:3353: Add Docker-based integration tests for k8s ServiceAccountValidator (#1271) (cherry picked from commit 24dccd701813c370b766f9fd3a9ed1ebef26bbd4) --- .github/workflows/build/Dockerfile | 1 + .../workflows/build/conf/topologies/k8sauth.xml | 52 ++++++++++++ .github/workflows/compose/docker-compose.yml | 71 +++++++++++++++- .../workflows/compose/k8s/manifests/test-sa.yaml | 38 +++++++++ .../tests/test_k8s_serviceaccount_validation.py | 94 ++++++++++++++++++++++ 5 files changed, 255 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build/Dockerfile b/.github/workflows/build/Dockerfile index c06db8cf4..5c0407661 100644 --- a/.github/workflows/build/Dockerfile +++ b/.github/workflows/build/Dockerfile @@ -43,6 +43,7 @@ ADD .github/workflows/build/conf/topologies/knoxtoken.xml /knox-runtime/conf/top ADD .github/workflows/build/conf/topologies/health.xml /knox-runtime/conf/topologies/health.xml ADD .github/workflows/build/conf/topologies/knoxldap.xml /knox-runtime/conf/topologies/knoxldap.xml ADD .github/workflows/build/conf/topologies/remoteauth.xml /knox-runtime/conf/topologies/remoteauth.xml +ADD .github/workflows/build/conf/topologies/k8sauth.xml /knox-runtime/conf/topologies/k8sauth.xml ADD .github/workflows/build/conf/topologies/knoxidf-ldap.xml /knox-runtime/conf/topologies/knoxidf-ldap.xml ADD .github/workflows/build/conf/topologies/knoxidf-token.xml /knox-runtime/conf/topologies/knoxidf-token.xml diff --git a/.github/workflows/build/conf/topologies/k8sauth.xml b/.github/workflows/build/conf/topologies/k8sauth.xml new file mode 100644 index 000000000..1d7a1ed00 --- /dev/null +++ b/.github/workflows/build/conf/topologies/k8sauth.xml @@ -0,0 +1,52 @@ +<!-- +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> +<topology> + <gateway> + <provider> + <role>federation</role> + <name>HeaderPreAuth</name> + <enabled>true</enabled> + <param> + <name>preauth.validation.method</name> + <value>preauth.k8s.service.account.validation</value> + </param> + <!-- + HeaderPreAuth extracts the asserted principal from this header. + The k8s ServiceAccountValidator independently reads the asserted + user from its own 'preauth.k8s.sa.custom.header' param, which + defaults to 'x-knoxidf-obo.username' - the same header set here, + so the principal and the validated user line up. + --> + <param> + <name>preauth.custom.header</name> + <value>x-knoxidf-obo.username</value> + </param> + <param> + <name>preauth.k8s.sa.spiffe.header</name> + <value>x-spiffe-id</value> + </param> + <param> + <name>preauth.k8s.sa.user.annotation</name> + <value>knox.apache.org/owner-username</value> + </param> + </provider> + </gateway> + <service> + <role>HEALTH</role> + </service> +</topology> diff --git a/.github/workflows/compose/docker-compose.yml b/.github/workflows/compose/docker-compose.yml index 3364c7f08..35036921d 100644 --- a/.github/workflows/compose/docker-compose.yml +++ b/.github/workflows/compose/docker-compose.yml @@ -26,15 +26,81 @@ services: depends_on: - knox-dev + # Lightweight single-node Kubernetes (k3s) used to back the k8s + # ServiceAccountValidator. It exposes a real API server that the fabric8 + # client inside Knox talks to. --tls-san=k3s makes the API server cert valid + # for the "k3s" compose hostname so the rewritten kubeconfig can target it. + k3s: + image: rancher/k3s:v1.31.5-k3s1 + command: + - server + - --disable=traefik + - --disable=metrics-server + - --disable=servicelb + - --disable=local-storage + - --tls-san=k3s + - --write-kubeconfig-mode=666 + privileged: true + environment: + - K3S_KUBECONFIG_OUTPUT=/output/kubeconfig.yaml + tmpfs: + - /run + - /var/run + volumes: + - k3s-output:/output + + # One-shot bootstrap: waits for the k3s API, rewrites the generated kubeconfig + # so its server points at the "k3s" hostname (instead of 127.0.0.1), and + # applies the test ServiceAccount fixtures. Knox waits for this to finish. + k8s-bootstrap: + image: rancher/k3s:v1.31.5-k3s1 + entrypoint: + - /bin/sh + - -c + command: + - | + set -e + echo 'Waiting for k3s kubeconfig...' + until [ -f /output/kubeconfig.yaml ]; do sleep 2; done + export KUBECONFIG=/output/knox-kubeconfig.yaml + echo 'Waiting for k3s API to be ready...' + # k3s regenerates its CA and rewrites kubeconfig.yaml on every (re)start, + # and a persisted volume may still hold a stale copy from a prior run. + # Re-derive the knox kubeconfig on each attempt so we converge on the live + # CA: the loop only exits once kubectl actually connects with it. + until + sed 's#https://127.0.0.1:6443#https://k3s:6443#g' \ + /output/kubeconfig.yaml > /output/knox-kubeconfig.yaml + kubectl get --raw='/readyz' >/dev/null 2>&1 + do + sleep 3 + done + echo 'Applying test fixtures...' + kubectl apply -f /manifests/ + kubectl get serviceaccount test-sa -n test + echo 'k8s bootstrap complete' + volumes: + - k3s-output:/output + - ./k8s/manifests:/manifests:ro + depends_on: + - k3s + knox: image: apache/knox-dev:${IMAGE_TAG:-master} command: /gateway.sh + environment: + # Point the fabric8 client used by the k8s ServiceAccountValidator at k3s. + - KUBECONFIG=/k3s/knox-kubeconfig.yaml volumes: # - ./topologies:/knox-runtime/conf/topologies - ./logs:/knox-runtime/logs # - ./knoxshell:/knoxshell + - k3s-output:/k3s:ro depends_on: - - ldap + ldap: + condition: service_started + k8s-bootstrap: + condition: service_completed_successfully tests: image: python:3.10-slim @@ -51,3 +117,6 @@ services: && pytest --junitxml=test-results.xml" depends_on: - knox + +volumes: + k3s-output: diff --git a/.github/workflows/compose/k8s/manifests/test-sa.yaml b/.github/workflows/compose/k8s/manifests/test-sa.yaml new file mode 100644 index 000000000..bf88f5476 --- /dev/null +++ b/.github/workflows/compose/k8s/manifests/test-sa.yaml @@ -0,0 +1,38 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with this +# work for additional information regarding copyright ownership. The ASF +# licenses this file to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Fixtures applied to the throwaway k3s cluster by the k8s-bootstrap service. +# The Knox container connects to k3s as cluster admin (via the generated +# kubeconfig), so the Role/RoleBinding a real deployment would use to grant the +# Knox ServiceAccount read access are not needed here - only the annotated +# ServiceAccount the validator looks up matters. +# +# SPIFFE id under test: spiffe://cluster.local/ns/test/sa/test-sa +# -> namespace "test", service account "test-sa" +# The validator reads the 'knox.apache.org/owner-username' annotation ("bob") +# and compares it to the asserted user from the x-knoxidf-obo.username header. +--- +apiVersion: v1 +kind: Namespace +metadata: + name: test +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: test-sa + namespace: test + annotations: + knox.apache.org/owner-username: "bob" diff --git a/.github/workflows/tests/test_k8s_serviceaccount_validation.py b/.github/workflows/tests/test_k8s_serviceaccount_validation.py new file mode 100644 index 000000000..c304240ad --- /dev/null +++ b/.github/workflows/tests/test_k8s_serviceaccount_validation.py @@ -0,0 +1,94 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration tests for the k8s pre-auth ServiceAccountValidator. + +These exercise the 'k8sauth' topology (HeaderPreAuth federation backed by the +k8s ServiceAccountValidator) against a throwaway k3s cluster. The cluster has a +ServiceAccount 'test-sa' in namespace 'test' annotated with +'knox.apache.org/owner-username: bob'. The validator parses the SPIFFE id, looks +up that ServiceAccount, and only lets the request through when the annotation +value equals the asserted user from the x-knoxidf-obo.username header. +""" + +import unittest + +from common_utils import gateway_base_url, knox_get + +# A request is authenticated only when both headers are present, the SPIFFE id +# parses to namespace 'test' / service account 'test-sa', and the asserted user +# matches the ServiceAccount's owner-username annotation ("bob"). +SPIFFE_HEADER = "x-spiffe-id" +USER_HEADER = "x-knoxidf-obo.username" +VALID_SPIFFE_ID = "spiffe://cluster.local/ns/test/sa/test-sa" + + +class TestK8sServiceAccountValidation(unittest.TestCase): + """SPIFFE id to ServiceAccount annotation matching via the k8sauth topology.""" + + def setUp(self): + self.base_url = gateway_base_url() + # 'ping' always returns 200/OK once auth passes, so it isolates the + # auth outcome from gateway readiness (unlike 'gateway-status'). + self.ping_url = self.base_url + "gateway/k8sauth/v1/ping" + + def test_matching_user_is_authorized(self): + """Asserted user matching the SA annotation passes pre-auth (200, OK).""" + response = knox_get( + self.ping_url, + headers={SPIFFE_HEADER: VALID_SPIFFE_ID, USER_HEADER: "bob"}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.text.strip(), "OK") + + def test_mismatching_user_is_forbidden(self): + """Asserted user not matching the SA annotation is rejected (403).""" + response = knox_get( + self.ping_url, + headers={SPIFFE_HEADER: VALID_SPIFFE_ID, USER_HEADER: "sam"}, + ) + self.assertEqual(response.status_code, 403) + + def test_missing_spiffe_header_is_forbidden(self): + """Without the SPIFFE header there is nothing to validate (403).""" + response = knox_get(self.ping_url, headers={USER_HEADER: "bob"}) + self.assertEqual(response.status_code, 403) + + def test_missing_user_header_is_forbidden(self): + """Without the asserted-user header the principal is missing (403).""" + response = knox_get(self.ping_url, headers={SPIFFE_HEADER: VALID_SPIFFE_ID}) + self.assertEqual(response.status_code, 403) + + def test_unparseable_spiffe_id_is_forbidden(self): + """A SPIFFE id that is not in ns/sa form cannot be resolved (403).""" + response = knox_get( + self.ping_url, + headers={SPIFFE_HEADER: "spiffe://cluster.local/not/a/valid/path", + USER_HEADER: "bob"}, + ) + self.assertEqual(response.status_code, 403) + + def test_unknown_service_account_is_forbidden(self): + """A SPIFFE id pointing at a non-existent ServiceAccount is rejected (403).""" + response = knox_get( + self.ping_url, + headers={SPIFFE_HEADER: "spiffe://cluster.local/ns/test/sa/does-not-exist", + USER_HEADER: "bob"}, + ) + self.assertEqual(response.status_code, 403) + + +if __name__ == "__main__": + unittest.main()
