This is an automated email from the ASF dual-hosted git repository.
smolnar82 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/knox.git
The following commit(s) were added to refs/heads/master by this push:
new 24dccd701 KNOX:3353: Add Docker-based integration tests for k8s
ServiceAccountValidator (#1271)
24dccd701 is described below
commit 24dccd701813c370b766f9fd3a9ed1ebef26bbd4
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)
---
.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 68e6d6dd2..1781ead94 100644
--- a/.github/workflows/build/Dockerfile
+++ b/.github/workflows/build/Dockerfile
@@ -42,6 +42,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
RUN chown -R gateway /knox-runtime/
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()