diff --git a/Makefile b/Makefile index 93581a28..0d08b197 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ LOCAL_NODE_BIN := $(LOCAL_NODE_DIR)/bin LOCAL_NODE := $(LOCAL_NODE_BIN)/node LOCAL_NPM := $(LOCAL_NODE_BIN)/npm -.PHONY: proto build test test-nested-modules tidy lint generate build-sdk docker-build docker-build-e2e-client docker-build-etcd-tools docker-clean ensure-minio start-minio stop-containers release-broker-ports test-produce-consume test-produce-consume-debug test-consumer-group test-ops-api test-mcp test-multi-segment-durability test-full test-operator test-acl demo demo-platform demo-platform-bootstrap iceberg-demo kafsql-demo platform-demo help clean-kind-all ensure-local-node check vet race fmt fmt-check test-fuzz code-ql code-ql-summary code-ql-gate commit-check +.PHONY: proto build test test-nested-modules tidy lint generate build-sdk docker-build docker-build-e2e-client docker-build-etcd-tools docker-clean ensure-minio start-minio stop-containers release-broker-ports test-produce-consume test-produce-consume-debug test-consumer-group test-ops-api test-mcp test-multi-segment-durability test-full test-operator test-acl demo demo-platform demo-platform-bootstrap iceberg-demo kafsql-demo platform-demo help clean-kind-all ensure-local-node check vet race fmt fmt-check test-fuzz test-chart-psa code-ql code-ql-summary code-ql-gate commit-check REGISTRY ?= ghcr.io/kafscale STAMP_DIR ?= .build @@ -205,6 +205,9 @@ fmt-check: ## Check formatting (fails if unformatted) test-fuzz: ## Run Go fuzz test(s) bash scripts/test_fuzz.sh +test-chart-psa: ## Run the PodSecurity restricted conformance chart test (helm only, no cluster) + bash test/chart/psa-restricted_test.sh + code-ql: ensure-local-node ## Run local CodeQL and emit SARIF under .tmp/codeql/ bash scripts/codeql_local.sh diff --git a/deploy/helm/kafscale/Chart.yaml b/deploy/helm/kafscale/Chart.yaml index ded5f027..251575c8 100644 --- a/deploy/helm/kafscale/Chart.yaml +++ b/deploy/helm/kafscale/Chart.yaml @@ -19,5 +19,5 @@ description: Helm chart for the Kafscale operator and console home: https://github.com/KafScale/platform icon: https://raw.githubusercontent.com/KafScale/platform/main/docs/assets/icon.png type: application -version: 0.4.0 +version: 0.4.2 appVersion: "v1.5.0" diff --git a/deploy/helm/kafscale/templates/console-deployment.yaml b/deploy/helm/kafscale/templates/console-deployment.yaml index d4d7ebf2..2bdda290 100644 --- a/deploy/helm/kafscale/templates/console-deployment.yaml +++ b/deploy/helm/kafscale/templates/console-deployment.yaml @@ -40,11 +40,19 @@ spec: {{- range .Values.imagePullSecrets }} - name: {{ . }} {{- end }} +{{- end }} +{{- with .Values.console.podSecurityContext }} + securityContext: +{{ toYaml . | indent 8 }} {{- end }} containers: - name: console image: "{{ .Values.console.image.repository }}:{{ ternary "latest" (default .Chart.AppVersion .Values.console.image.tag) .Values.console.image.useLatest }}" imagePullPolicy: {{ ternary "Always" .Values.console.image.pullPolicy .Values.console.image.useLatest }} +{{- with .Values.console.containerSecurityContext }} + securityContext: +{{ toYaml . | indent 12 }} +{{- end }} env: - name: KAFSCALE_CONSOLE_HTTP_ADDR value: ":8080" diff --git a/deploy/helm/kafscale/templates/mcp-deployment.yaml b/deploy/helm/kafscale/templates/mcp-deployment.yaml index e8768c27..f52e074d 100644 --- a/deploy/helm/kafscale/templates/mcp-deployment.yaml +++ b/deploy/helm/kafscale/templates/mcp-deployment.yaml @@ -43,10 +43,18 @@ spec: {{- end }} {{- end }} serviceAccountName: {{ include "kafscale.mcpServiceAccountName" . }} +{{- with .Values.mcp.podSecurityContext }} + securityContext: +{{ toYaml . | indent 8 }} +{{- end }} containers: - name: mcp image: "{{ .Values.mcp.image.repository }}:{{ ternary "latest" (default .Chart.AppVersion .Values.mcp.image.tag) .Values.mcp.image.useLatest }}" imagePullPolicy: {{ ternary "Always" .Values.mcp.image.pullPolicy .Values.mcp.image.useLatest }} +{{- with .Values.mcp.containerSecurityContext }} + securityContext: +{{ toYaml . | indent 12 }} +{{- end }} env: - name: KAFSCALE_MCP_HTTP_ADDR value: ":8090" diff --git a/deploy/helm/kafscale/templates/operator-deployment.yaml b/deploy/helm/kafscale/templates/operator-deployment.yaml index 37368aa5..51c3ca15 100644 --- a/deploy/helm/kafscale/templates/operator-deployment.yaml +++ b/deploy/helm/kafscale/templates/operator-deployment.yaml @@ -40,11 +40,19 @@ spec: {{- range .Values.imagePullSecrets }} - name: {{ . }} {{- end }} +{{- end }} +{{- with .Values.operator.podSecurityContext }} + securityContext: +{{ toYaml . | indent 8 }} {{- end }} containers: - name: operator image: "{{ .Values.operator.image.repository }}:{{ ternary "latest" (default .Chart.AppVersion .Values.operator.image.tag) .Values.operator.image.useLatest }}" imagePullPolicy: {{ ternary "Always" .Values.operator.image.pullPolicy .Values.operator.image.useLatest }} +{{- with .Values.operator.containerSecurityContext }} + securityContext: +{{ toYaml . | indent 12 }} +{{- end }} ports: - name: metrics containerPort: {{ .Values.operator.metrics.port }} diff --git a/deploy/helm/kafscale/templates/proxy-deployment.yaml b/deploy/helm/kafscale/templates/proxy-deployment.yaml index 7f984633..42e05a74 100644 --- a/deploy/helm/kafscale/templates/proxy-deployment.yaml +++ b/deploy/helm/kafscale/templates/proxy-deployment.yaml @@ -40,11 +40,19 @@ spec: {{- range .Values.imagePullSecrets }} - name: {{ . }} {{- end }} +{{- end }} +{{- with .Values.proxy.podSecurityContext }} + securityContext: +{{ toYaml . | indent 8 }} {{- end }} containers: - name: proxy image: "{{ .Values.proxy.image.repository }}:{{ ternary "latest" (default .Chart.AppVersion .Values.proxy.image.tag) .Values.proxy.image.useLatest }}" imagePullPolicy: {{ ternary "Always" .Values.proxy.image.pullPolicy .Values.proxy.image.useLatest }} +{{- with .Values.proxy.containerSecurityContext }} + securityContext: +{{ toYaml . | indent 12 }} +{{- end }} env: - name: KAFSCALE_PROXY_ADDR value: ":{{ .Values.proxy.service.port }}" @@ -107,6 +115,16 @@ spec: {{- else }} {} {{- end }} + # The proxy LFS verify path writes a temp file via os.CreateTemp("", ...) + # which resolves to /tmp. With readOnlyRootFilesystem: true the root FS is + # read-only, so /tmp must be a writable mount or LFS verify returns HTTP 500. + # This emptyDir keeps the restricted default and LFS coexisting. + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} {{- with .Values.proxy.nodeSelector }} nodeSelector: {{ toYaml . | indent 8 }} diff --git a/deploy/helm/kafscale/values.yaml b/deploy/helm/kafscale/values.yaml index a5ae4f9e..a49b091f 100644 --- a/deploy/helm/kafscale/values.yaml +++ b/deploy/helm/kafscale/values.yaml @@ -57,6 +57,26 @@ operator: nodeSelector: {} tolerations: [] affinity: {} + # Pod-level security context. PSA-restricted compliant defaults. + # These defaults assume the shipped images (UID 10001). If you override + # image.repository with a root-running image, set + # operator.podSecurityContext.runAsNonRoot=false. Override per-environment + # via `--set operator.podSecurityContext.=` or a values file. + podSecurityContext: + runAsNonRoot: true + # runAsUser/runAsGroup pin the Dockerfile-baked USER 10001. If the image + # USER changes, change this in lockstep. PSA restricted itself only + # requires runAsNonRoot, not a specific UID. + runAsUser: 10001 + runAsGroup: 10001 + seccompProfile: + type: RuntimeDefault + # Container-level security context. PSA-restricted-required defaults. + containerSecurityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] metrics: enabled: true port: 8080 @@ -107,6 +127,23 @@ console: nodeSelector: {} tolerations: [] affinity: {} + # PSA-restricted compliant defaults. The console image runs as + # USER 10001 (Dockerfile-baked). These defaults assume the shipped image; + # if you override image.repository with a root-running image, set + # console.podSecurityContext.runAsNonRoot=false. + podSecurityContext: + runAsNonRoot: true + # runAsUser/runAsGroup pin the Dockerfile-baked USER 10001. Change in + # lockstep with the image USER. PSA restricted requires only runAsNonRoot. + runAsUser: 10001 + runAsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containerSecurityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] service: type: ClusterIP port: 80 @@ -146,6 +183,27 @@ proxy: nodeSelector: {} tolerations: [] affinity: {} + # PSA-restricted compliant defaults. The proxy image runs as + # USER 10001 (Dockerfile-baked). These defaults assume the shipped image; + # if you override image.repository with a root-running image, set + # proxy.podSecurityContext.runAsNonRoot=false. + # fsGroup is kept here (unlike operator/console) because the proxy mounts a + # writable /tmp emptyDir for the LFS verify path; fsGroup sets that volume's + # group ownership so the non-root process can write to it. + podSecurityContext: + runAsNonRoot: true + # runAsUser/runAsGroup pin the Dockerfile-baked USER 10001. Change in + # lockstep with the image USER. PSA restricted requires only runAsNonRoot. + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containerSecurityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] service: type: LoadBalancer port: 9092 @@ -211,6 +269,23 @@ mcp: nodeSelector: {} tolerations: [] affinity: {} + # PSA-restricted compliant defaults. The mcp image runs as + # USER 10001 (Dockerfile-baked, deploy/docker/mcp.Dockerfile). These + # defaults assume the shipped image; if you override image.repository with + # a root-running image, set mcp.podSecurityContext.runAsNonRoot=false. + podSecurityContext: + runAsNonRoot: true + # runAsUser/runAsGroup pin the Dockerfile-baked USER 10001. Change in + # lockstep with the image USER. PSA restricted requires only runAsNonRoot. + runAsUser: 10001 + runAsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containerSecurityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] service: type: ClusterIP port: 80 diff --git a/test/chart/psa-restricted_test.sh b/test/chart/psa-restricted_test.sh new file mode 100755 index 00000000..89d9c425 --- /dev/null +++ b/test/chart/psa-restricted_test.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Copyright 2026 KafScale team. +# +# Licensed 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. + +# Chart conformance gate for the PodSecurity "restricted" profile. +# +# Asserts that every chart-templated Deployment (operator, proxy, console, +# mcp) renders the five restricted controls: +# 1. pod-level runAsNonRoot: true +# 2. container allowPrivilegeEscalation: false +# 3. container capabilities.drop includes ALL +# 4. pod-level seccompProfile.type: RuntimeDefault +# 5. pod-level runAsUser: (a non-root UID) +# +# This is the gate that catches a future Deployment being added without the +# securityContext blocks (e.g. the mcp gap this test was written to close). +# Self-contained: needs only helm and awk, no helm plugins, no cluster. +# Run directly or via `make test-chart-psa`. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHART_DIR="${ROOT_DIR}/deploy/helm/kafscale" + +command -v helm >/dev/null 2>&1 || { echo "helm is required"; exit 1; } + +fail=0 + +# Render a single Deployment template with every workload enabled. The proxy +# and mcp are opt-in (enabled: false by default), so we enable all four here. +render_deployment() { + local template="$1" + helm template kafscale "${CHART_DIR}" \ + --show-only "templates/${template}" \ + --set proxy.enabled=true \ + --set console.enabled=true \ + --set mcp.enabled=true +} + +# assert_present +assert_present() { + local component="$1" desc="$2" yaml="$3" pattern="$4" + if printf '%s\n' "${yaml}" | grep -Eq "${pattern}"; then + echo "PASS: ${component}: ${desc}" + else + echo "FAIL: ${component}: ${desc} (pattern: ${pattern})" + fail=1 + fi +} + +# assert_runasuser_nonzero +# runAsUser must be present and not 0 (0 is root, which violates restricted). +assert_runasuser_nonzero() { + local component="$1" yaml="$2" uid + uid="$(printf '%s\n' "${yaml}" | awk '/^[[:space:]]*runAsUser:/ { print $2; exit }')" + if [ -n "${uid}" ] && [ "${uid}" != "0" ]; then + echo "PASS: ${component}: runAsUser is a non-root UID (runAsUser=${uid})" + else + echo "FAIL: ${component}: runAsUser must be a non-root UID (got \"${uid:-}\")" + fail=1 + fi +} + +echo "==> chart PSA-restricted conformance gate" + +# The chart-templated Deployments and their template files. Broker/etcd are +# operator-reconciled (label app=kafscale-broker), not chart-templated, and +# are intentionally out of scope for this chart-level gate. +declare -a COMPONENTS=( + "operator:operator-deployment.yaml" + "proxy:proxy-deployment.yaml" + "console:console-deployment.yaml" + "mcp:mcp-deployment.yaml" +) + +for entry in "${COMPONENTS[@]}"; do + component="${entry%%:*}" + template="${entry##*:}" + yaml="$(render_deployment "${template}")" + + # 1. runAsNonRoot: true + assert_present "${component}" "runAsNonRoot: true" "${yaml}" \ + '^[[:space:]]*runAsNonRoot:[[:space:]]*true[[:space:]]*$' + # 2. allowPrivilegeEscalation: false + assert_present "${component}" "allowPrivilegeEscalation: false" "${yaml}" \ + '^[[:space:]]*allowPrivilegeEscalation:[[:space:]]*false[[:space:]]*$' + # 3. capabilities.drop includes ALL + assert_present "${component}" "capabilities.drop includes ALL" "${yaml}" \ + '^[[:space:]]*-[[:space:]]*ALL[[:space:]]*$' + # 4. seccompProfile RuntimeDefault + assert_present "${component}" "seccompProfile.type: RuntimeDefault" "${yaml}" \ + '^[[:space:]]*type:[[:space:]]*RuntimeDefault[[:space:]]*$' + # 5. non-root runAsUser + assert_runasuser_nonzero "${component}" "${yaml}" +done + +if [ "${fail}" -ne 0 ]; then + echo "==> chart PSA-restricted conformance gate FAILED" + exit 1 +fi +echo "==> chart PSA-restricted conformance gate passed (4 Deployments x 5 controls)"