Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
52 changes: 52 additions & 0 deletions .github/workflows/build/conf/topologies/k8sauth.xml
Original file line number Diff line number Diff line change
@@ -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>
71 changes: 70 additions & 1 deletion .github/workflows/compose/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -51,3 +117,6 @@ services:
&& pytest --junitxml=test-results.xml"
depends_on:
- knox

volumes:
k3s-output:
38 changes: 38 additions & 0 deletions .github/workflows/compose/k8s/manifests/test-sa.yaml
Original file line number Diff line number Diff line change
@@ -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"
94 changes: 94 additions & 0 deletions .github/workflows/tests/test_k8s_serviceaccount_validation.py
Original file line number Diff line number Diff line change
@@ -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()
Loading