#!/bin/bash # OIDC Workload Identity E2E Test # # This script tests OIDC workload identity federation with Kubernetes ServiceAccount tokens. # It uses the native Kubernetes ServiceAccount issuer (not an external OIDC provider like Dex). # # The test: # 1. Creates a Kind cluster with the API server OIDC discovery endpoint exposed # 2. Exports the Kind cluster's CA certificate # 3. Deploys Zot with OIDC bearer authentication # 4. Creates a test Pod with a projected ServiceAccount token # 5. Verifies authentication succeeds with the token # 6. Verifies authentication fails without the token # # Usage: # ./kind-oidc-workload-identity.sh [OPTIONS] # # Options: # --skip-setup Skip cluster creation, image building, and initial setup # (assumes resources already exist from a previous run) # --only-crane Only run crane e2e tests # --only-curl Only run curl-based tests (includes conditional access) # --keep-resources Don't clean up resources on exit (useful for debugging) # --help Show this help message set -o errexit set -o pipefail # Parse command line arguments SKIP_SETUP=false ONLY_CRANE=false ONLY_CURL=false KEEP_RESOURCES=false while [[ $# -gt 0 ]]; do case $1 in --skip-setup) SKIP_SETUP=true shift ;; --only-crane) ONLY_CRANE=true shift ;; --only-curl) ONLY_CURL=true shift ;; --keep-resources) KEEP_RESOURCES=true shift ;; --help) sed -n '2,/^$/p' "$0" | grep -E "^#" | sed 's/^# *//' exit 0 ;; *) echo "Unknown option: $1" echo "Use --help for usage information" exit 1 ;; esac done # Check prerequisites check_prerequisites() { local missing="" for cmd in docker kubectl jq openssl curl git; do if ! command -v "$cmd" &> /dev/null; then missing="$missing $cmd" fi done if [ -n "$missing" ]; then echo "Error: Missing required tools:$missing" exit 1 fi } check_prerequisites ROOT_DIR=$(git rev-parse --show-toplevel) cd "${ROOT_DIR}" # Use project's kind if available, otherwise fall back to system kind if [ -x "${ROOT_DIR}/hack/tools/bin/kind" ]; then KIND="${ROOT_DIR}/hack/tools/bin/kind" elif command -v kind &> /dev/null; then KIND="kind" else echo "Error: kind not found. Install kind or run 'make ${ROOT_DIR}/hack/tools/bin/kind'" exit 1 fi CLUSTER_NAME="kind-oidc-wid" ZOT_REG_NAME="zot-oidc-wid" ZOT_PORT="5000" TEST_NAMESPACE="oidc-test" TEST_SA_NAME="test-workload" AUDIENCE="zot-registry" # Pin image versions for reproducibility and to avoid Docker Hub rate limiting issues # These versions should be updated periodically # Note: BUSYBOX_IMAGE uses gcr.io to avoid Docker Hub rate limits for crane operations CURL_IMAGE="curlimages/curl:8.5.0" ALPINE_IMAGE="alpine:3.19" BUSYBOX_IMAGE="gcr.io/google-containers/busybox:1.27" KIND_NODE_IMAGE="kindest/node:v1.28.7" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color log_info() { echo -e "${GREEN}[INFO]${NC} $1" } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1" } log_error() { echo -e "${RED}[ERROR]${NC} $1" } # Helper function to ensure a pod exists and is ready # Usage: ensure_pod_ready # Returns 0 if pod is ready, 1 if it couldn't be created/started ensure_pod_ready() { local pod_name="$1" local namespace="$2" local timeout="${3:-120}" if kubectl get pod "$pod_name" -n "$namespace" &>/dev/null; then local pod_status pod_status=$(kubectl get pod "$pod_name" -n "$namespace" -o jsonpath='{.status.phase}') if [ "$pod_status" = "Running" ]; then log_info "Pod '$pod_name' already exists and is running (reusing)" return 0 else log_info "Pod '$pod_name' exists but status is '$pod_status', waiting..." fi else return 1 # Pod doesn't exist, caller should create it fi # Wait for pod to be ready kubectl wait --for=condition=Ready "pod/$pod_name" -n "$namespace" --timeout="${timeout}s" } cleanup() { if [ "$KEEP_RESOURCES" = true ]; then log_info "Keeping resources (--keep-resources specified)" log_info "To clean up manually, run:" log_info " ${KIND} delete cluster --name ${CLUSTER_NAME}" log_info " docker rm -f ${ZOT_REG_NAME}" return fi log_info "Cleaning up..." "${KIND}" delete cluster --name "${CLUSTER_NAME}" 2>/dev/null || true docker rm -f "${ZOT_REG_NAME}" 2>/dev/null || true rm -f /tmp/kind-ca.pem /tmp/zot-oidc-config.json /tmp/test-token.txt 2>/dev/null || true } trap cleanup EXIT # Set no_proxy if applicable if [ -n "${no_proxy}" ]; then log_info "Updating no_proxy env var" export no_proxy="${no_proxy},${ZOT_REG_NAME}" export NO_PROXY="${no_proxy}" fi # Pre-pull images to avoid Docker Hub rate limiting issues in CI # This is done early so failures are caught before cluster creation prepull_images() { log_info "Pre-pulling container images (helps avoid Docker Hub rate limiting)..." local images=("${CURL_IMAGE}" "${ALPINE_IMAGE}" "${BUSYBOX_IMAGE}" "${KIND_NODE_IMAGE}") for img in "${images[@]}"; do log_info "Pulling ${img}..." if ! docker pull "${img}" 2>/dev/null; then log_warn "Failed to pull ${img} - will retry during test (may be rate limited)" fi done } # Skip setup if requested if [ "$SKIP_SETUP" = true ]; then log_info "Skipping setup (--skip-setup specified)" log_info "Using existing cluster '${CLUSTER_NAME}' and zot container '${ZOT_REG_NAME}'" # Verify resources exist if ! "${KIND}" get clusters 2>/dev/null | grep -q "${CLUSTER_NAME}"; then log_error "Cluster '${CLUSTER_NAME}' does not exist. Run without --skip-setup first." exit 1 fi if ! docker ps --format '{{.Names}}' | grep -q "^${ZOT_REG_NAME}$"; then log_error "Zot container '${ZOT_REG_NAME}' is not running. Run without --skip-setup first." exit 1 fi # Set kubectl context kubectl config use-context "kind-${CLUSTER_NAME}" # Get the OIDC issuer URL CONTROL_PLANE_CONTAINER="${CLUSTER_NAME}-control-plane" OIDC_ISSUER="https://${CONTROL_PLANE_CONTAINER}:6443" else # Delete existing cluster if it exists log_info "Cleaning up any existing resources..." "${KIND}" delete cluster --name "${CLUSTER_NAME}" 2>/dev/null || true docker rm -f "${ZOT_REG_NAME}" 2>/dev/null || true # Pre-pull images to avoid rate limiting prepull_images # Create Kind cluster with custom configuration # - Configure the ServiceAccount issuer to be accessible from zot (via docker network) # - Add the container name as a SAN to the API server certificate log_info "Creating Kind cluster '${CLUSTER_NAME}'..." cat <:6443/system:serviceaccount::" cat < /tmp/zot-oidc-config.json { "distSpecVersion": "1.1.1", "storage": { "rootDirectory": "/var/lib/zot" }, "http": { "address": "0.0.0.0", "port": "${ZOT_PORT}", "auth": { "bearer": { "realm": "zot", "service": "zot-registry", "oidc": [ { "issuer": "${OIDC_ISSUER}", "audiences": ["${AUDIENCE}"], "certificateAuthorityFile": "/etc/zot/kind-ca.pem" } ] } }, "accessControl": { "repositories": { "**": { "policies": [ { "users": ["${OIDC_ISSUER}/system:serviceaccount:${TEST_NAMESPACE}:${TEST_SA_NAME}"], "actions": ["read", "create", "update", "delete"] } ], "defaultPolicy": [] }, "cond-*/**": { "policies": [ { "users": ["${OIDC_ISSUER}/system:serviceaccount:${TEST_NAMESPACE}:other-sa"], "actions": ["read", "create", "update", "delete"], "conditions": [ { "expression": "req.repository.startsWith(\"cond-allowed/\")", "message": "other-sa may only push to cond-allowed/*" } ] } ], "defaultPolicy": [] } } } }, "log": { "level": "debug" } } EOF log_info "Zot configuration:" cat /tmp/zot-oidc-config.json # Run zot container connected to the kind network log_info "Starting zot container..." docker run -d \ --name "${ZOT_REG_NAME}" \ --network kind \ -p "127.0.0.1:${ZOT_PORT}:${ZOT_PORT}" \ -v /tmp/zot-oidc-config.json:/etc/zot/config.json:ro \ -v /tmp/kind-ca.pem:/etc/zot/kind-ca.pem:ro \ "${IMAGE_NAME}" \ serve /etc/zot/config.json # Wait for zot to be ready log_info "Waiting for zot to be ready..." sleep 5 # Check zot logs log_info "Zot container logs:" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 # Get zot container IP on the kind network ZOT_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${ZOT_REG_NAME}") log_info "Zot container IP: ${ZOT_IP}" # Verify zot is running and responding log_info "Checking zot health..." for i in {1..30}; do # zot should return 401 for unauthenticated requests when bearer auth is configured HTTP_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:${ZOT_PORT}/v2/" 2>/dev/null || echo "000") if [ "$HTTP_RESPONSE" = "401" ] || [ "$HTTP_RESPONSE" = "200" ]; then log_info "Zot is responding (HTTP $HTTP_RESPONSE)" break fi if [ $i -eq 30 ]; then log_error "Zot failed to start (HTTP $HTTP_RESPONSE)" docker logs "${ZOT_REG_NAME}" exit 1 fi sleep 1 done fi # End of setup section (skip-setup conditional) # Create test namespace and ServiceAccount log_info "Creating test namespace and ServiceAccount..." kubectl create namespace "${TEST_NAMESPACE}" --dry-run=client -o yaml | kubectl apply -f - kubectl create serviceaccount "${TEST_SA_NAME}" -n "${TEST_NAMESPACE}" --dry-run=client -o yaml | kubectl apply -f - # Create a test Pod with projected ServiceAccount token (or reuse existing) # Using a lightweight image with wget/curl for testing if ! ensure_pod_ready "oidc-test-pod" "${TEST_NAMESPACE}" 120; then log_info "Creating test Pod with projected ServiceAccount token..." cat < /tmp/test-token.txt log_info "Token claims (decoded):" # Decode the JWT payload (second part, base64url encoded) PAYLOAD=$(cat /tmp/test-token.txt | cut -d'.' -f2) # Add padding if needed and decode PAYLOAD_PADDED="${PAYLOAD}$(printf '%*s' $((4 - ${#PAYLOAD} % 4)) | tr ' ' '=')" echo "${PAYLOAD_PADDED}" | base64 -d 2>/dev/null | jq . || log_warn "Could not decode token (may need jq)" # ============================================================================= # CURL-BASED TESTS (Tests 1-7) # ============================================================================= if [ "$ONLY_CRANE" = true ]; then log_info "Skipping curl-based tests (--only-oras specified)" else # Test 1: Verify PUSH fails without token log_info "TEST 1: Verifying push (blob upload) fails without token..." HTTP_CODE=$(kubectl exec -n "${TEST_NAMESPACE}" oidc-test-pod -- \ curl -s -o /dev/null -w "%{http_code}" -X POST "http://${ZOT_REG_NAME}:${ZOT_PORT}/v2/test-repo/blobs/uploads/" 2>/dev/null || echo "000") if [ "$HTTP_CODE" = "401" ]; then log_info "TEST 1 PASSED: Push correctly rejected without token (HTTP $HTTP_CODE)" else log_error "TEST 1 FAILED: Expected 401, got HTTP $HTTP_CODE" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 exit 1 fi # Test 2: Verify authentication SUCCEEDS with token log_info "TEST 2: Verifying authentication succeeds with token..." RESPONSE=$(kubectl exec -n "${TEST_NAMESPACE}" oidc-test-pod -- \ sh -c 'TOKEN=$(cat /var/run/secrets/tokens/zot-token); curl -s -w "\n%{http_code}" -H "Authorization: Bearer $TOKEN" "http://${ZOT_REGISTRY}/v2/_catalog"') HTTP_CODE=$(echo "$RESPONSE" | tail -1) BODY=$(echo "$RESPONSE" | head -n -1) if [ "$HTTP_CODE" = "200" ]; then log_info "TEST 2 PASSED: Authentication succeeded with token (HTTP $HTTP_CODE)" log_info "Response body: $BODY" else log_error "TEST 2 FAILED: Authentication failed with valid token (HTTP $HTTP_CODE)" log_error "Response: $BODY" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -50 exit 1 fi # Test 3: Initiate a blob upload (tests write permissions) log_info "TEST 3: Testing write permissions (initiate blob upload)..." RESPONSE=$(kubectl exec -n "${TEST_NAMESPACE}" oidc-test-pod -- \ sh -c 'TOKEN=$(cat /var/run/secrets/tokens/zot-token); curl -s -w "\n%{http_code}" -X POST -H "Authorization: Bearer $TOKEN" "http://${ZOT_REGISTRY}/v2/test-repo/blobs/uploads/"') HTTP_CODE=$(echo "$RESPONSE" | tail -1) if [ "$HTTP_CODE" = "202" ]; then log_info "TEST 3 PASSED: Write operation succeeded (HTTP $HTTP_CODE - upload initiated)" else log_error "TEST 3 FAILED: Write operation failed (HTTP $HTTP_CODE)" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 exit 1 fi # Test 4: List the catalog to verify repository was created log_info "TEST 4: Listing catalog to verify repository exists..." CATALOG=$(kubectl exec -n "${TEST_NAMESPACE}" oidc-test-pod -- \ sh -c 'TOKEN=$(cat /var/run/secrets/tokens/zot-token); curl -s -H "Authorization: Bearer $TOKEN" "http://${ZOT_REGISTRY}/v2/_catalog"') log_info "Catalog: ${CATALOG}" if echo "$CATALOG" | grep -q "test-repo"; then log_info "TEST 4 PASSED: Repository 'test-repo' found in catalog" else log_warn "TEST 4: Repository may not appear immediately in catalog (this is expected)" fi # Test 5: Verify wrong audience token fails log_info "TEST 5: Verifying wrong audience token fails..." # Create another pod with a different audience (or reuse existing) if ! ensure_pod_ready "oidc-test-pod-wrong-aud" "${TEST_NAMESPACE}" 60; then cat </dev/null || echo "000") if [ "$HTTP_CODE" = "401" ]; then log_info "TEST 5 PASSED: Wrong audience token correctly rejected (HTTP $HTTP_CODE)" else log_error "TEST 5 FAILED: Expected 401 for wrong audience, got HTTP $HTTP_CODE" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 exit 1 fi # Test 6: Verify different ServiceAccount with correct audience is authenticated # Note: Currently, OIDC bearer auth only performs authentication, not repository-level authorization. # The BaseAuthzHandler in zot bypasses authorization checks for bearer auth. # This test verifies that a different SA with the correct audience CAN authenticate (proving OIDC works) # but has NO permissions because it's not in the accessControl config. # The username is derived from the token and used for authorization checks. log_info "TEST 6: Verifying different ServiceAccount authenticates but has NO permissions..." # Create a different ServiceAccount kubectl create serviceaccount other-sa -n "${TEST_NAMESPACE}" --dry-run=client -o yaml | kubectl apply -f - # Create pod with the other ServiceAccount (or reuse existing) if ! ensure_pod_ready "oidc-test-pod-other-sa" "${TEST_NAMESPACE}" 60; then cat </dev/null || echo "{}") if ! echo "$CATALOG_RESPONSE" | jq -e '.repositories | index("test-repo")' >/dev/null 2>&1; then log_info "TEST 6 PASSED: Other ServiceAccount authenticated but cannot see test-repo" log_info " The username '${OIDC_ISSUER}/system:serviceaccount:${TEST_NAMESPACE}:other-sa' was extracted from the token." log_info " Authorization is enforced via accessControl config." log_info " Catalog: $CATALOG_RESPONSE" else log_error "TEST 6 FAILED: Expected test-repo to be absent from other-sa's catalog" log_error "Got: $CATALOG_RESPONSE" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 exit 1 fi # ============================================================================= # TEST 7: Verify other-sa gets 403 when trying to write (authorization enforced) # This is a NON-conditional deny: no policy on the matched pattern grants # other-sa, so the response body must NOT carry a `reason` field — that # field is reserved for condition-driven denies (see Test 9 for the contrast). # ============================================================================= log_info "TEST 7: Verifying other-sa gets 403 Forbidden when trying to write..." # `-w "\n%{http_code}"` appends the status code on its own line after the body, # so we can split with shell builtins. RESPONSE=$(kubectl exec -n "${TEST_NAMESPACE}" oidc-test-pod-other-sa -- \ sh -c 'TOKEN=$(cat /var/run/secrets/tokens/zot-token); curl -s -w "\n%{http_code}" -X POST -H "Authorization: Bearer $TOKEN" "http://${ZOT_REGISTRY}/v2/unauthorized-repo/blobs/uploads/"' 2>/dev/null || echo $'\n000') TEST7_HTTP=$(echo "$RESPONSE" | tail -n1) TEST7_BODY=$(echo "$RESPONSE" | sed '$d') if [ "$TEST7_HTTP" != "403" ]; then log_error "TEST 7 FAILED: Expected 403 for write operation, got HTTP $TEST7_HTTP" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 exit 1 fi # A non-conditional deny must NOT surface a reason. jq -e returns non-zero if # .errors[0].detail.reason is absent or null, which is what we want here. if echo "$TEST7_BODY" | jq -e '.errors[0].detail.reason // empty' >/dev/null 2>&1; then log_error "TEST 7 FAILED: Non-conditional 403 unexpectedly carried a reason: $TEST7_BODY" exit 1 fi log_info "TEST 7 PASSED: Other ServiceAccount rejected (HTTP 403, no reason in body)" # ============================================================================= # TEST 8: Conditional access GRANTS other-sa write on cond-allowed/* (condition true) # ============================================================================= # The accessControl config grants other-sa read/create/update/delete on the # `cond-*/**` pattern only when `req.repository.startsWith("cond-allowed/")` # is true. A push to `cond-allowed/test` should be authorized by the # conditional policy (HTTP 202 Accepted on blob upload start). log_info "TEST 8: Verifying CEL condition grants other-sa write on cond-allowed/*..." HTTP_CODE=$(kubectl exec -n "${TEST_NAMESPACE}" oidc-test-pod-other-sa -- \ sh -c 'TOKEN=$(cat /var/run/secrets/tokens/zot-token); curl -s -o /dev/null -w "%{http_code}" -X POST -H "Authorization: Bearer $TOKEN" "http://${ZOT_REGISTRY}/v2/cond-allowed/test/blobs/uploads/"' 2>/dev/null || echo "000") if [ "$HTTP_CODE" = "202" ]; then log_info "TEST 8 PASSED: Conditional policy grants write on cond-allowed/* (HTTP 202)" else log_error "TEST 8 FAILED: Expected 202 for cond-allowed/test write, got HTTP $HTTP_CODE" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 exit 1 fi # ============================================================================= # TEST 9: Conditional access DENIES other-sa on cond-denied/* and surfaces the # operator-authored Message in the 403 response body's error detail # ============================================================================= # Same conditional policy, but `cond-denied/*` does not satisfy # `startsWith("cond-allowed/")`. The policy's `message` should appear in the # response body's error detail under the `reason` key, so the client knows # why access was denied. log_info "TEST 9: Verifying CEL condition denies on cond-denied/* and surfaces reason..." RESPONSE=$(kubectl exec -n "${TEST_NAMESPACE}" oidc-test-pod-other-sa -- \ sh -c 'TOKEN=$(cat /var/run/secrets/tokens/zot-token); curl -s -w "\n%{http_code}" -X POST -H "Authorization: Bearer $TOKEN" "http://${ZOT_REGISTRY}/v2/cond-denied/test/blobs/uploads/"' 2>/dev/null || echo $'\n000') TEST9_HTTP=$(echo "$RESPONSE" | tail -n1) TEST9_BODY=$(echo "$RESPONSE" | sed '$d') if [ "$TEST9_HTTP" != "403" ]; then log_error "TEST 9 FAILED: Expected 403 for cond-denied/* write, got HTTP $TEST9_HTTP" log_error "Body: $TEST9_BODY" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 exit 1 fi # Conditional denies must surface the operator-authored Message in # detail.reason — that's the contrast with Test 7. if ! echo "$TEST9_BODY" | jq -e '.errors[0].detail.reason | contains("only push to cond-allowed/*")' >/dev/null 2>&1; then log_error "TEST 9 FAILED: Expected deny reason in response body, got: $TEST9_BODY" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 exit 1 fi log_info "TEST 9 PASSED: Conditional deny returned HTTP 403 with reason in body" fi # End of curl-based tests conditional # ============================================================================= # E2E Tests using Crane CLI for real OCI image operations # ============================================================================= # NOTE: Crane (from go-containerregistry) properly supports the `registryToken` # field in Docker config, which sends the token directly as a Bearer header. # This is compatible with zot's OIDC bearer authentication. # # Other tools like oras and skopeo do NOT properly support this - they expect # the token service authentication flow (exchanging credentials with a token # endpoint) which is different from direct bearer token authentication. # ============================================================================= if [ "$ONLY_CURL" = true ]; then log_info "Skipping crane e2e tests (--only-curl specified)" else # Create a Pod with crane CLI for e2e artifact push/pull tests (or reuse existing) if ! ensure_pod_ready "crane-test-pod" "${TEST_NAMESPACE}" 120; then log_info "Creating crane test Pod for e2e artifact operations..." cat < ~/.docker/config.json << EOFCONFIG { "auths": { "$ZOT_REGISTRY": { "registryToken": "$TOKEN" } } } EOFCONFIG ' } # Helper function to remove Docker config (no auth) remove_crane_auth() { kubectl exec -n "${TEST_NAMESPACE}" crane-test-pod -- sh -c ' rm -f ~/.docker/config.json rm -f /tmp/auth.json rm -rf ~/.config/containers ' 2>/dev/null || true } # ============================================================================= # TEST 8: Copy OCI image using crane WITH auth (should SUCCEED) # Note: This runs first to populate zot, so subsequent tests don't need Docker Hub # We check if the image already exists to avoid Docker Hub rate limiting on reruns # ============================================================================= log_info "TEST 8: Copying OCI image using crane WITH auth (should succeed)..." setup_crane_auth # Check if image already exists in zot from a previous run IMAGE_EXISTS=$(kubectl exec -n "${TEST_NAMESPACE}" crane-test-pod -- \ sh -c 'crane manifest --insecure $ZOT_REGISTRY/crane-test:v1 2>&1 && echo EXISTS' || true) if echo "$IMAGE_EXISTS" | grep -q "EXISTS"; then log_info "Image crane-test:v1 already exists in zot, skipping Docker Hub pull" # Verify we can still access it with auth (do a copy to v1-test to verify write works) PUSH_OUTPUT=$(kubectl exec -n "${TEST_NAMESPACE}" crane-test-pod -- \ sh -c 'crane copy --insecure $ZOT_REGISTRY/crane-test:v1 $ZOT_REGISTRY/crane-test:v1-test 2>&1') || true if echo "$PUSH_OUTPUT" | grep -qiE "pushed|existing|copied|digest"; then log_info "TEST 8 PASSED: crane copy within zot succeeded with auth" log_info "Push output: $(echo "$PUSH_OUTPUT" | tail -2)" else log_error "TEST 8 FAILED: crane copy within zot failed" log_error "Output: $PUSH_OUTPUT" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -50 exit 1 fi else # Image doesn't exist, need to pull from Docker Hub log_info "Image not found in zot, pulling from Docker Hub..." PUSH_OUTPUT=$(kubectl exec -n "${TEST_NAMESPACE}" crane-test-pod -- \ sh -c "crane copy --insecure ${BUSYBOX_IMAGE} \$ZOT_REGISTRY/crane-test:v1 2>&1") || true if echo "$PUSH_OUTPUT" | grep -qiE "pushed|existing|crane-test:v1.*digest|copied"; then log_info "TEST 8 PASSED: crane copy from Docker Hub succeeded with auth" log_info "Push output: $(echo "$PUSH_OUTPUT" | tail -3)" else log_error "TEST 8 FAILED: crane copy failed with valid auth" log_error "Output: $PUSH_OUTPUT" log_error "Note: If you see rate limit errors, this may be due to Docker Hub throttling" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -50 exit 1 fi fi # Verify the image was pushed by listing tags log_info "Verifying pushed image (listing tags)..." TAGS=$(kubectl exec -n "${TEST_NAMESPACE}" crane-test-pod -- \ sh -c 'crane ls --insecure $ZOT_REGISTRY/crane-test 2>&1') || true log_info "Tags for crane-test: $TAGS" if echo "$TAGS" | grep -q "v1"; then log_info "Verified: Tag 'v1' found in crane-test repository" else log_warn "Warning: Tag 'v1' not found in crane-test repository" fi # ============================================================================= # TEST 9: Copy OCI image using crane WITHOUT auth (should FAIL) # Note: Uses zot-to-zot copy to avoid Docker Hub rate limiting # ============================================================================= log_info "TEST 9: Copying OCI image using crane WITHOUT auth (should fail)..." remove_crane_auth COPY_NO_AUTH_OUTPUT=$(kubectl exec -n "${TEST_NAMESPACE}" crane-test-pod -- \ sh -c 'crane copy --insecure $ZOT_REGISTRY/crane-test:v1 $ZOT_REGISTRY/crane-test:v2 2>&1' || true) if echo "$COPY_NO_AUTH_OUTPUT" | grep -qiE "401|unauthorized|UNAUTHORIZED"; then log_info "TEST 9 PASSED: crane copy correctly rejected without auth (401)" log_info "Output: $(echo "$COPY_NO_AUTH_OUTPUT" | tail -2)" else log_error "TEST 9 FAILED: Expected 401 authentication failure" log_error "Output: $COPY_NO_AUTH_OUTPUT" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 exit 1 fi # ============================================================================= # TEST 10: List tags using crane WITHOUT auth (should FAIL) # ============================================================================= log_info "TEST 10: Listing tags using crane WITHOUT auth (should fail)..." PULL_OUTPUT=$(kubectl exec -n "${TEST_NAMESPACE}" crane-test-pod -- \ sh -c 'crane ls --insecure $ZOT_REGISTRY/crane-test 2>&1' || true) if echo "$PULL_OUTPUT" | grep -qiE "401|unauthorized|authentication|UNAUTHORIZED"; then log_info "TEST 10 PASSED: crane ls correctly rejected without auth" log_info "Output: $(echo "$PULL_OUTPUT" | tail -2)" else log_error "TEST 10 FAILED: Expected authentication failure" log_error "Output: $PULL_OUTPUT" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 exit 1 fi # ============================================================================= # TEST 11: Pull manifest using crane WITH auth (should SUCCEED) # ============================================================================= log_info "TEST 11: Pulling manifest using crane WITH auth (should succeed)..." setup_crane_auth PULL_OUTPUT=$(kubectl exec -n "${TEST_NAMESPACE}" crane-test-pod -- \ sh -c 'crane manifest --insecure $ZOT_REGISTRY/crane-test:v1 2>&1') || true if echo "$PULL_OUTPUT" | grep -qiE "schemaVersion|mediaType|manifests"; then log_info "TEST 11 PASSED: crane manifest succeeded with auth" log_info "Manifest preview: $(echo "$PULL_OUTPUT" | head -5)" else log_error "TEST 11 FAILED: crane manifest failed with valid auth" log_error "Output: $PULL_OUTPUT" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -50 exit 1 fi # ============================================================================= # CRANE TESTS FOR other-sa (NOT in accessControl config) # These tests verify that authorization is enforced for real OCI operations # ============================================================================= # Create crane pod for other-sa (or reuse existing) if ! ensure_pod_ready "crane-other-sa-pod" "${TEST_NAMESPACE}" 120; then log_info "Creating crane pod for other-sa..." cat < ~/.docker/config.json << EOFCONFIG { "auths": { "$ZOT_REGISTRY": { "registryToken": "$TOKEN" } } } EOFCONFIG ' } # ============================================================================= # TEST 12: Copy OCI image using crane with other-sa (should FAIL with 403) # Note: Uses zot-to-zot copy to avoid Docker Hub rate limiting # ============================================================================= log_info "TEST 12: Copying OCI image using crane with other-sa (should fail with 403)..." setup_other_sa_crane_auth PUSH_OUTPUT=$(kubectl exec -n "${TEST_NAMESPACE}" crane-other-sa-pod -- \ sh -c 'crane copy --insecure $ZOT_REGISTRY/crane-test:v1 $ZOT_REGISTRY/other-sa-crane-test:v1 2>&1' || true) if echo "$PUSH_OUTPUT" | grep -qiE "403|forbidden|denied"; then log_info "TEST 12 PASSED: crane copy correctly rejected for other-sa (403 Forbidden)" log_info "Output: $(echo "$PUSH_OUTPUT" | tail -2)" else log_error "TEST 12 FAILED: Expected 403 for other-sa push" log_error "Output: $PUSH_OUTPUT" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 exit 1 fi # ============================================================================= # TEST 13: List tags using crane with other-sa (should FAIL with 403) # ============================================================================= log_info "TEST 13: Listing tags using crane with other-sa (should fail with 403)..." LIST_OUTPUT=$(kubectl exec -n "${TEST_NAMESPACE}" crane-other-sa-pod -- \ sh -c 'crane ls --insecure $ZOT_REGISTRY/crane-test 2>&1' || true) if echo "$LIST_OUTPUT" | grep -qiE "403|forbidden|unauthorized|denied"; then log_info "TEST 13 PASSED: crane ls correctly rejected for other-sa (access denied)" log_info "Output: $(echo "$LIST_OUTPUT" | tail -2)" else log_error "TEST 13 FAILED: Expected 403 for other-sa list" log_error "Output: $LIST_OUTPUT" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 exit 1 fi # ============================================================================= # TEST 14: Crane operation with NO token (should FAIL with 401, not 403) # This verifies the difference between authentication failure (401) and # authorization failure (403) # ============================================================================= log_info "TEST 14: Crane operation with NO token (should fail with 401)..." # Remove auth config from other-sa pod kubectl exec -n "${TEST_NAMESPACE}" crane-other-sa-pod -- sh -c 'rm -f ~/.docker/config.json' 2>/dev/null || true NO_TOKEN_OUTPUT=$(kubectl exec -n "${TEST_NAMESPACE}" crane-other-sa-pod -- \ sh -c 'crane ls --insecure $ZOT_REGISTRY/crane-test 2>&1' || true) if echo "$NO_TOKEN_OUTPUT" | grep -qiE "401|unauthorized"; then log_info "TEST 14 PASSED: No token correctly returns 401 Unauthorized" log_info "Output: $(echo "$NO_TOKEN_OUTPUT" | tail -2)" else log_error "TEST 14 FAILED: Expected 401 for no token, got different error" log_error "Output: $NO_TOKEN_OUTPUT" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 exit 1 fi fi # End of crane tests conditional # Print final zot logs for debugging log_info "Final zot logs:" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -50 log_info "==========================================" if [ "$ONLY_CRANE" = true ]; then log_info "Crane e2e tests (8-14) PASSED!" elif [ "$ONLY_CURL" = true ]; then log_info "Curl-based tests (1-7) PASSED!" else log_info "All OIDC Workload Identity tests PASSED!" fi log_info "==========================================" log_info "" log_info "Iteration tips:" log_info " --skip-setup Skip cluster/image/zot setup (reuse existing)" log_info " --only-crane Run only crane tests (8-14)" log_info " --only-curl Run only curl tests (1-7)" log_info " --keep-resources Keep cluster/zot running after exit"