graph TD
subgraph Threats["Attack Surfaces"]
T1["Network Sniffing<br/>Plaintext listeners expose<br/>message content on the wire"]
T2["Unauthorized Access<br/>Missing ACLs allow any<br/>authenticated user to read/write<br/>any topic"]
T3["Credential Compromise<br/>SCRAM passwords in Kubernetes<br/>Secrets can be extracted<br/>by anyone with Secret read access"]
T4["Operator Privilege Escalation<br/>The Strimzi operator has<br/>broad cluster permissions —<br/>compromise gives cluster admin"]
end
subgraph Mitigations["Kates Mitigations"]
M1["TLS listener (port 9093)<br/>encrypts all traffic"]
M2["Per-user ACLs with<br/>minimum required permissions"]
M3["RBAC limiting<br/>Secret access"]
M4["NetworkPolicies isolating<br/>operator namespace"]
end
T1 --> M1
T2 --> M2
T3 --> M3
T4 --> M4
16 Security & Compliance
Table of contents
A Kafka cluster is a high-value target. It carries your business data, your audit trails, and your event streams. A misconfigured listener, an overly-permissive ACL, or a missing network policy can expose all of it. This chapter covers every security layer in the Kates platform — not just what is configured, but why each layer exists and what would happen without it.
Use this chapter as a reference when auditing your deployment, onboarding new services, or designing your own security posture for production. After this chapter, you can:
- Explain which listener each client authenticates on, and why the performance listener deliberately skips TLS
- Onboard a new service with a least-privilege
KafkaUser— scoped ACLs, prefix patterns, and quotas - Test what the shipped NetworkPolicies block and what they leave open, and close the listener ports to pods you have not granted
- Rotate the
kates-backendSCRAM password without leaving the Kates API on the old one - Grade your cluster’s posture with
kates security auditand act on the findings
16.1 Threat Model
Before diving into configurations, it helps to understand what you’re defending against. A Kafka cluster on Kubernetes has four primary attack surfaces:
The default performance test listener (plain, port 9092) uses SCRAM authentication but no TLS encryption. This is intentional — TLS adds measurable CPU overhead, and performance baselines should isolate Kafka throughput from encryption cost. For production deployments, always use the tls listener (port 9093) or the external listener (port 9094, which enables TLS by default).
16.2 Security Architecture Overview
The diagram shows who talks to whom across the platform, and over which protocol and port. One hop deserves a second look: the Kates API reaches the brokers on the plain listener, port 9092, where SCRAM authenticates the client but nothing encrypts the traffic.
graph TB
subgraph External
CLI[Kates CLI]
CI[CI Pipeline]
end
subgraph Kubernetes
subgraph Kates NS
API[Kates API<br/>REST + gRPC]
end
subgraph Kafka NS
Brokers[Kafka Brokers]
UI[Kafka UI]
CC[Cruise Control]
Operator[Strimzi Operator]
end
subgraph Monitoring NS
Prom[Prometheus]
Graf[Grafana]
end
end
CLI -->|"REST / gRPC"| API
CI -->|"gRPC (protobuf)"| API
API -->|"SCRAM-SHA-512<br/>port 9092"| Brokers
UI -->|"SCRAM-SHA-512<br/>port 9092"| Brokers
Prom -->|"JMX scrape<br/>port 9404"| Brokers
Operator -->|"mTLS<br/>K8s API"| Brokers
16.3 Authentication
Authentication answers the question: “Who are you?” Every connection to Kafka must prove its identity before it can do anything.
Kafka Listeners
Each listener enforces a specific authentication mechanism. The choice of listener determines both the security properties and the performance characteristics of the connection:
| Listener | Port | Auth | Protocol | Clients | When to Use |
|---|---|---|---|---|---|
plain |
9092 | SCRAM-SHA-512 | SASL_PLAINTEXT | Kates, Kafka UI, Apicurio | Performance testing baselines (no TLS overhead) |
tls |
9093 | mTLS (certificate) | SSL | Encrypted internal | When you need wire encryption between services |
external |
9094 | SCRAM-SHA-512 | SASL_SSL | External tools, CI | Access from outside the Kubernetes cluster, where the values chain declares it |
The chart’s base values declare only plain and tls. values-prod.yaml and, outside kind, kates deploy add external, as What the Shipped Policies Block explains.
SCRAM-SHA-512
SCRAM (Salted Challenge Response Authentication Mechanism) is a password-based authentication protocol that never sends the password over the wire [42, 54]. Instead, the client and server exchange salted hashes in a challenge-response sequence. SHA-512 provides strong hashing — brute-forcing a SCRAM-SHA-512 password is computationally expensive.
Strimzi generates SCRAM credentials automatically when you create a KafkaUser resource. The password is stored in a Kubernetes Secret with the same name as the user:
# View generated password
kubectl get secret kafka-ui -n kafka -o jsonpath='{.data.password}' | base64 -dKubernetes Secrets are base64-encoded, not encrypted. Anyone with RBAC permission to read Secrets in the kafka namespace can extract every SCRAM password, and the copy of the kates-backend password in the kates namespace is just as exposed. RBAC on Secrets in both namespaces is the wall around these credentials. A NetworkPolicy does not help here: Secrets are read from the Kubernetes API, not from the brokers.
The Kates API’s own service account can’t read these Secrets, and doesn’t need to: the kubelet hands the Kates API pod its kates-backend password. The kates chart grants the account get on one Secret, postgresql, the password of the INTEGRATION_CDC test’s database, and only in the namespaces its rbac.cdcSecretNamespaces lists.
Cross-Namespace Credential Synchronization
Strimzi writes a KafkaUser’s Secret only into the Kafka cluster’s namespace (kafka). Kates runs in kates, so it needs a copy of the kates-backend Secret there. kates deploy makes that copy each time it installs or reconciles the Kates API, and so do make kates-secret and scripts/deploy-kates.sh. Nothing keeps the copy in step between those runs: no controller or policy that ships with Kates watches the source Secret.
A synchronized copy would not be enough on its own anyway. The Kates API pod reads the password once, at startup, into the KATES_KAFKA_SASL_PASSWORD environment variable, and never reads the Secret again. Whatever keeps the copy current — the steps below, or a Kyverno clone rule or External Secrets that you add yourself — Kates uses a new password only after a restart.
With kates deploy --topology single, Kates and Kafka share one namespace, kates-stack unless you passed --namespace, and Kates reads the Strimzi Secret itself. There is no copy to refresh, but the restart is still needed: in the procedure below, skip step 3, run the kubectl commands of the other steps against that namespace, and pass it to kates ports with --app-ns.
Password Rotation
Strimzi does not rotate SCRAM passwords on its own. You rotate one by deleting the user’s Secret: the User Operator generates a new password, sets it on the brokers and writes a new Secret. From then on the old password no longer authenticates.
For kates-backend, that is a short outage of Kates, which lasts until Kates restarts with the new password. Run the steps back to back:
# 1. Delete the Secret; the User Operator generates a new password
kubectl delete secret kates-backend -n kafka
# 2. Wait for the new Secret. The KafkaUser is already Ready, so waiting on
# its Ready condition returns at once and proves nothing.
for _ in $(seq 60); do
kubectl get secret kates-backend -n kafka >/dev/null 2>&1 && break
sleep 5
done
# 3. Copy the new password into the Kates namespace
PASSWORD=$(kubectl get secret kates-backend -n kafka -o jsonpath='{.data.password}' | base64 -d)
test -n "$PASSWORD" && kubectl create secret generic kates-backend -n kates \
--from-literal=password="$PASSWORD" --dry-run=client -o yaml | kubectl apply -f -
# 4. Restart Kates so it reads the new password
kubectl rollout restart deployment/kates -n kates
kubectl rollout status deployment/kates -n kates --timeout=300s
# 5. The restart ended any port-forward to the old pod: start the forwards again
kates ports
# 6. Check that Kates can authenticate to Kafka again
kates healthA port-forward to a Service is bound to the one pod it picked when it started, and neither kates ports nor make ports reconnects it when that pod goes away, so step 5 is what makes the API reachable again. kates ports also points the ports CLI context at the forward it starts and makes it the current context. Skip it if you reach the API through an Ingress or a NodePort.
A finished rollout does not prove the rotation worked. The readiness probe does not depend on Kafka (kates.health.readiness.require-kafka is false), so the new pod turns Ready even with a wrong password. kates health has the Kates API describe the cluster over its own SASL connection, and its Kafka Cluster block must show UP and “Kafka cluster is reachable”. The command needs only the API URL of your CLI context, because /api/health is served without the API key.
From step 1 until the restarted pod is Ready, Kates cannot open a new connection to Kafka. Connections it already holds keep working, because Kafka does not re-authenticate them, but every new one fails SASL authentication: the producers and consumers of a new test, or a reconnect after a broker restarts. Rotate when no test or chaos experiment is running.
mTLS (Mutual TLS)
The TLS listener requires both server and client certificates [45]. Strimzi issues client certificates via the Clients CA when a KafkaUser uses authentication.type: tls. This provides the strongest authentication — both sides cryptographically verify each other’s identity, and the connection is encrypted end-to-end.
16.5 Certificate Management
Strimzi manages two independent CA hierarchies — one for cluster-internal communication and one for client authentication:
graph TD
ClusterCA["Cluster CA<br/>5yr validity<br/>Renew 180d before expiry"] --> BrokerCert["Broker Certs"]
ClusterCA --> ControllerCert["Controller Certs"]
ClusterCA --> CCCert["Cruise Control Cert"]
ClientsCA["Clients CA<br/>5yr validity<br/>Renew 180d before expiry"] --> UserCert["Client certs for KafkaUsers<br/>with authentication.type: tls"]
The Clients CA issues certificates only for KafkaUser resources with authentication.type: tls. The default managed users all authenticate with SCRAM-SHA-512, so they receive password Secrets rather than client certificates.
| Property | Value | Purpose |
|---|---|---|
| Validity | 1825 days (5 years) | Long-lived for stability |
| Renewal window | 180 days before expiry | Ample time for rollout |
| Renewal policy | replace-key |
New key pair on renewal (stronger than key reuse) |
The replace-key renewal policy means that on each renewal, Strimzi generates an entirely new private key rather than reusing the existing one. This is more secure — if the old key was compromised, the new certificate uses a fresh key pair.
Rotation Monitoring
Strimzi sets the NotAfter date on each certificate. Read it directly with:
kubectl get secret krafter-cluster-ca-cert -n kafka \
-o jsonpath='{.data.ca\.crt}' | base64 -d | openssl x509 -noout -datesFor alerting, use the rules the strimzi-operator chart ships instead of writing your own. The Cluster Operator publishes strimzi_certificate_expiration_timestamp_ms for the cluster and clients CA certificates of every Kafka cluster it manages, and the chart’s PrometheusRule, strimzi-operator-operator-alerts in the strimzi-operator namespace, alerts on it:
| Alert | Fires When the Certificate Expires In | Severity |
|---|---|---|
KafkaCertificateExpiringSoon |
Under 30 days, for 1h | warning |
KafkaCertificateExpiryCritical |
Under 7 days, for 30m | critical |
The thresholds are the chart’s alerts.thresholds.certificateWarningDays and alerts.thresholds.certificateCriticalDays. Strimzi renews a CA 180 days before it expires, so either alert means a renewal did not happen: look at the operator’s logs and at the cluster’s maintenanceTimeWindows, outside which Strimzi does not renew.
The chart renders the rule, and the PodMonitor that scrapes the operator, only if the monitoring.coreos.com API exists when the release is installed or upgraded. kates deploy installs the operator before the monitoring stack, so after the first deploy on a fresh cluster neither object exists. Check:
kubectl get prometheusrule,podmonitor -n strimzi-operatorIf nothing is listed, run kates deploy again with the flags you first used. It upgrades the operator release on every run, and this time the API is there. Each of those upgrades passes --reset-values and the kind or generic overlay, so on an operator you manage with Helm yourself it drops values-prod.yaml and every value you set. For that operator, upgrade the release yourself with its own values, as Kafka Deployment Engineering shows, which renders both objects now that the API exists, and pass --with-strimzi=false whenever you run kates deploy.
16.6 Audit Logging
Kafka’s authorizer can log every authorization decision — who accessed what, and when. The authorizer itself is already configured on the krafter cluster: Strimzi derives it from spec.kafka.authorization (type: simple), and it does not allow authorizer.class.name to be set directly in the config section — unsupported keys placed there are filtered out. What you control is the log level of kafka.authorizer.logger, which lives under spec.kafka.logging in the Kafka CR:
spec:
kafka:
logging:
type: inline
loggers:
rootLogger.level: INFO
# Kafka 4.x brokers use Log4j2: declare the logger by name, then set its level
logger.authorizer.name: kafka.authorizer.logger
logger.authorizer.level: DEBUGAt the default INFO level the authorizer logs denied operations only. At DEBUG, every produce, consume, and admin operation generates an audit entry that includes the principal, the resource, the operation, and the decision (ALLOWED or DENIED). These logs are invaluable during security incident investigations.
Turn DEBUG on for an investigation, then set the logger back to INFO. The authorizer checks every request, so at DEBUG each broker writes a line for every authorized produce and fetch request, including those of kates-backend: super-user status skips the ACLs but not the log. Under a Kates load test that can be thousands of lines a second per broker, which costs broker CPU and log storage and skews the baseline you are measuring.
For lighter-weight auditing, Kates records every change made through the Kates API, over REST or gRPC, in its audit log: what was done, to what, and who did it. Who is the principal of the API key used (see GET /api/whoami), marked as a person, an agent, or system:scheduler for a schedule’s firing. A refused or failed change leaves a row too, with how it ended; a request without a valid key leaves none. When a change started a test run or a disruption, its row’s target is that run’s or report’s id, which is how to find who started it: runs and reports record no owner of their own. Inspect the trail with kates audit, filtering with --type, --since and --actor. Rows written before the Kates API recorded actors name no one.
16.7 Network Policies
Network policies decide which pods can open a socket to the brokers at all, so that a compromised pod elsewhere in the cluster cannot even try a stolen password. Know what the shipped ones do before you rely on them: as the charts ship, the client listeners are open to every pod in the cluster.
How the Policies Combine
Before you let a client near the brokers, you want to know which pods can open a socket to them. The answer depends on how Kubernetes combines NetworkPolicies.
Read NetworkPolicies as a union, not as a chain of filters. A policy selects pods and lists the traffic it allows. A pod that no policy selects for ingress accepts every connection. Once any policy selects it for ingress, it accepts only what one of those policies allows: the sum of all their rules. So a connection that any policy allows gets through, whatever the other policies say. A deny-all closes only the doors that no other policy opens.
Each end of a connection is judged on its own. The broker’s policies decide whether the connection may come in, and the client’s decide whether it may go out. A grant on the broker side does nothing for a client whose own namespace denies egress, such as a namespace that Kyverno’s generated default-deny covers (see Automatic NetworkPolicy Generation).
Two owners write policies that select the krafter brokers and controllers. Read the last column as the part that matters for your clients:
| Policy | Written by | Present | Admits to plain and tls |
|---|---|---|---|
krafter-kafka |
The kafka-cluster chart |
Where the values chain renders the chart’s policies; the next section lists which chains do | Each networkPolicy.clients entry, kates.io/test-pod pods in kafka, the cluster’s own pods and the Cluster Operator |
krafter-network-policy-kafka |
The Strimzi Cluster Operator | In every profile: the operator chart leaves Strimzi’s policy generation on | The listener’s networkPolicyPeers, or every connection when the list is empty or missing |
Strimzi builds its rule for each listener from that listener’s networkPolicyPeers. An empty or missing list admits every connection. No listener in the chart’s values, its overlays or the values kates deploy generates sets the field, and the kafka.externalAccess preset has no place for it. So as shipped, Strimzi’s policy opens plain (9092) and tls (9093) to every pod in the cluster, and an external listener to every source.
Nothing the chart adds can take that access away. networkPolicy.clients adds allows, and krafter-default-deny adds none. Until you close the listeners, listener authentication (SCRAM-SHA-512 on plain, mutual TLS on tls) and ACLs are what keep an arbitrary pod away from your data.
Give every listener a networkPolicyPeers list and Strimzi’s rule shrinks to the peers you name. Where the chart’s policies render, the pods that can connect are then the union of the two lists: the pods the chart admits plus your peers. One narrow peer is enough, and networkPolicy.clients becomes the allow list. Where the chart’s policies don’t render, your peers are the only pods that can connect. Closing the Listeners shows how, over the values your release already has.
You can watch the union at work with make kafka-verify-policies. Its last step probes plain from a pod with no grant. On the charts as shipped, the probe connects, and the step fails and names Strimzi’s policy as the reason. Validating Policy Compliance explains how to read the result.
The model has limits. NetworkPolicies take effect only where the cluster’s network plugin enforces them. networkPolicyPeers never closes the metrics port, 9404, which Strimzi opens to every pod while metrics are on. For an external listener, the chart’s own rule admits every source unless kafka.externalAccess.allowedCidrs lists ranges. The chart applies those ranges only to the listener that kafka.externalAccess.name names, external by default.
What the Shipped Policies Block
Whether kafka-cluster renders any policy depends on the values chain. kates deploy starts its chain with .build/values-detected.yaml, which it writes from what it detects on the cluster, layers values-platform.yaml over it, and adds values-kind.yaml on a kind cluster:
| Values Chain | kafka-cluster NetworkPolicies |
|---|---|
values-kind.yaml, values-dev.yaml |
None: both set networkPolicy.enabled: false |
kates deploy on a cluster whose CNI it cannot identify, EKS, GKE and AKS excepted |
None: values-detected.yaml turns them off |
kates deploy on any other cluster |
Four of the six in Policy Summary, plus krafter-test-egress: the detected values turn off Cruise Control and the Kafka Exporter, and their policies with them |
The base values, values-staging.yaml, values-prod.yaml |
The six in Policy Summary, plus krafter-test-egress |
Where the chart’s policies render, they add two things:
- Egress from the brokers, controllers, Cruise Control, the Entity Operator and the Kafka Exporter is limited to the cluster’s own pods, DNS, and TCP 443 and 6443 to any address. Set
networkPolicy.apiServer.ipBlockto pin those two ports to the Kubernetes API server. - A deny-all,
krafter-default-deny, for every pod of the cluster, so that Cruise Control, the Entity Operator and the Kafka Exporter accept only what a policy allows them.
The internal ports are closed to other workloads in every profile, and not by the chart. The Strimzi Cluster Operator generates a policy of its own for every Kafka cluster, krafter-network-policy-kafka, unless its STRIMZI_NETWORK_POLICY_GENERATION is off (it is on by default). That policy admits only the cluster’s brokers and controllers and the Cluster Operator to 9090 (controller quorum and control plane), those and the cluster’s other operands to 9091 (replication), and only the operator to 8443 (the Kafka agent). It recognizes the operator by the label strimzi.io/kind: cluster-operator in any namespace, unless the operator knows the labels of its own namespace: set strimzi-kafka-operator.image.operatorNamespaceLabels on the strimzi-operator release, for example to kubernetes.io/metadata.name=strimzi-operator, to hold those rules to that namespace.
Neither policy restricts the client listeners, for the reason How the Policies Combine gives: no listener in the chart’s values or in values-detected.yaml has networkPolicyPeers. Whatever the values chain, this is who can connect:
| Port | Who Can Connect |
|---|---|
9092 (plain), 9093 (tls) |
Every pod in the cluster |
| 9404 (metrics) | Every pod in the cluster: Strimzi opens the metrics port to all while metrics.enabled is on, as it is by default |
9094 (external), wherever the chain declares it |
Every source. kafka.externalAccess.allowedCidrs narrows only the chart’s rule |
Two things declare 9094: kafka.externalAccess, which values-prod.yaml sets to a NodePort, and values-detected.yaml, which adds an external listener on every cluster but kind, a NodePort or, on EKS, GKE and AKS, a LoadBalancer. kates deploy therefore exposes 9094 outside kind although the base values leave kafka.externalAccess off. On EKS, GKE and AKS the detected listener annotates the bootstrap Service only, so every broker’s load balancer gets the provider’s default, usually a public address, and on EKS and GKE the bootstrap’s is public too; only the AKS bootstrap is internal. A default kates deploy on those clouds can therefore put 9094 on the internet, with TLS and SCRAM-SHA-512 as its only guard. Deployment Guide replaces that listener with internal load balancers that admit only the ranges you list.
Closing the Listeners
Give every listener in kafka.listeners a networkPolicyPeers list. One narrow peer is enough, for the reason How the Policies Combine gives: the pods the chart already admits keep their access, so networkPolicy.clients stays the one place where you grant access.
Helm replaces lists instead of merging them, so restate every listener the release has, not only the base values’ two. List them first:
kubectl get kafka krafter -n kafka -o jsonpath='{.spec.kafka.listeners[*].name}'On kind that prints plain tls, which the file below restates. If it also prints external, keep that listener too, in the form shown for port 9094 further down.
The file also grants Kafka UI, which the platform profile misses on the default install: kates deploy puts Kafka UI in the kafka namespace (--ui-ns defaults to kafka), and the profile grants app: kafka-ui only in kates and kafka-ui. Kafka UI reaches the brokers today through Strimzi’s open rule and loses them once the peers land, unless the file grants it:
# closed-listeners.yaml
kafka:
listeners:
- name: plain
port: 9092
type: internal
tls: false
authentication:
type: scram-sha-512
networkPolicyPeers:
- podSelector:
matchLabels:
kates.io/test-pod: "true"
- name: tls
port: 9093
type: internal
tls: true
authentication:
type: tls
networkPolicyPeers:
- podSelector:
matchLabels:
kates.io/test-pod: "true"
networkPolicy:
# The allow list the peers rely on; values-kind.yaml and values-dev.yaml turn it off
enabled: true
clients:
# kates deploy sets this entry: keep it, with the namespace you gave --connect-ns
- name: connect
namespace: connect
# Kafka UI where kates deploy installs it
- name: kafka-ui-in-kafka
namespace: kafka
podSelector: { app: kafka-ui }
listeners: [plain, tls]A peer with only a podSelector selects pods in the Kafka namespace, which here are the Helm test pods the chart admits anyway. The chart passes the list to the Kafka resource unchanged, and Strimzi rewrites its policy on the next reconciliation.
The peers rely on the chart’s policies: without them, Strimzi’s policy is the only one, and the peers you name are the only pods that can connect. That is why the file sets networkPolicy.enabled: true. Where kates deploy could not identify the CNI, that is not enough: values-detected.yaml turned the policies off with the older key networkPolicies.enabled: false, which the chart applies over a networkPolicy.enabled left at its default of true, so add networkPolicies: {enabled: true} to the file as well.
Apply the file over the values the release already has:
helm repo add seaweedfs https://seaweedfs.github.io/seaweedfs/helm
helm dependency build charts/kafka-cluster
helm upgrade krafter charts/kafka-cluster -n kafka --reuse-values -f closed-listeners.yaml
kubectl get networkpolicy krafter-kafka -n kafkaThe chart does not render until helm dependency build has filled its gitignored charts/ directory, and once Chart.lock exists the build fetches the SeaweedFS subchart only from a repository Helm has configured, hence the helm repo add, once per machine. --reuse-values keeps everything the release was installed with — for a kates deploy release, values-detected.yaml, the platform profile, the kind overlay, its --set flags and the version pins — and lays the file over it. Do not upgrade with the file alone: the release would lose the platform profile, and with it every networkPolicy.clients grant and the kates-backend super user, while the peers admit only test pods, so Kates, Kafka UI and Connect would lose the brokers. kates deploy has no flag for a values file of yours, and a later run leaves the release alone, because it skips a Kafka cluster that is already installed. The kubectl get must find krafter-kafka. If it does not, the chart’s policies are off and only the peers can connect: add the key from the previous paragraph and upgrade again.
Anything that reaches the brokers today without a networkPolicy.clients entry loses access when the peers land: an ad-hoc debug pod, a client nobody declared. Compare the client list with what actually connects before you roll this out, and run the probe in Testing Network Policies afterwards.
kafka.externalAccess.allowedCidrs does not close port 9094. It narrows the chart’s rule for the listener, but Strimzi’s policy still admits every source to a listener without networkPolicyPeers, and the externalAccess preset cannot carry them.
To restrict 9094, declare the listener yourself in kafka.listeners, named external, with ipBlock peers, and set kafka.externalAccess.type to none so that the preset does not replace it. allowedCidrs still narrows the chart’s rule for a listener of that name. Add the listener to closed-listeners.yaml as a third entry of kafka.listeners, after plain and tls, with the type and configuration it has in kubectl get kafka krafter -n kafka -o yaml (on EKS, GKE and AKS, kates deploy declares a LoadBalancer with provider annotations):
kafka:
externalAccess:
type: none
allowedCidrs: ["203.0.113.0/24"]
listeners:
# plain and tls come first, as above
- name: external
port: 9094
type: nodeport
tls: true
authentication:
type: scram-sha-512
configuration:
externalTrafficPolicy: Local
networkPolicyPeers:
- ipBlock:
cidr: 203.0.113.0/24externalTrafficPolicy: Local keeps the client’s address, where the infrastructure supports it, so the brokers see it. With the default, Cluster, a connection can arrive from a node’s address instead, which your ipBlock does not cover.
Port 9404 serves read-only metrics, and no listener setting closes it. Two settings do, each at a price. metrics.enabled: false, with alerts.enabled: false, which the chart requires with it, stops Strimzi opening the port, and the brokers stop exporting the metrics Prometheus scrapes there. The operator’s strimzi-kafka-operator.generateNetworkPolicy: false stops Strimzi generating policies at all, for every cluster that operator manages; the chart’s policies are then the only ones on the brokers, which also makes networkPolicy.clients the allow list for the listeners. That lasts only until the next kates deploy or make kafka: both upgrade the operator release with --reset-values, which turns generation back on. On that cluster, run kates deploy --with-strimzi=false, as the Upgrade Playbook advises for an operator you configure yourself. Turn generation off only where the chart’s policies render: under values-kind.yaml or values-dev.yaml, the brokers would have no policy at all, internal ports included.
Policy Summary
Every policy is named for its cluster, so two clusters can share a namespace. These are what kafka-cluster renders with the platform profile and the base listeners, beside the one Strimzi generates:
graph TD
subgraph Default["krafter-default-deny (Strimzi pods)"]
DNS["krafter-allow-dns<br/>UDP/TCP 53"]
end
subgraph Kafka["krafter-kafka (brokers + controllers)"]
B0["krafter pods ↔ 9090, 9091, 9092, 9093"]
B1["operator → 9090, 9091, 8443, 9092, 9093"]
B2["Kates API pods → 9092, 9093"]
B3["Litmus pods → 9092, 9093"]
B4["kafka-ui pod → 9092, 9093"]
B5["apicurio pod → 9092, 9093"]
B6["connect + MirrorMaker 2 → 9092, 9093"]
B7["kates.io/test-pod → 9092, 9093"]
B8["monitoring namespace → 9404"]
end
subgraph Generated["krafter-network-policy-kafka (generated by Strimzi)"]
G1["any pod, any namespace → 9092, 9093<br/>until the listeners carry networkPolicyPeers"]
G2["any pod → 9404<br/>while metrics are on"]
end
subgraph Operands
O1["krafter-cruise-control: operator 9090, monitoring 9404"]
O2["krafter-entity-operator: monitoring 8080, 8081"]
O3["krafter-kafka-exporter: monitoring 9404"]
end
subgraph Egress
E1["every policy: krafter pods + TCP 443, 6443"]
end
| Policy | Target | Ingress From | Ports |
|---|---|---|---|
krafter-default-deny |
Strimzi cluster pods (app.kubernetes.io/part-of: strimzi-krafter) |
None | None |
krafter-allow-dns |
Strimzi cluster pods | — (egress only) | 53 UDP/TCP |
krafter-kafka |
Broker and controller pods | krafter pods, operator, and every networkPolicy.clients entry; monitoring for metrics |
9090, 9091, 8443, 9092, 9093, 9404 |
krafter-cruise-control |
Cruise Control pod | Operator, monitoring | 9090, 9404 |
krafter-entity-operator |
Entity Operator pod | monitoring | 8080, 8081 |
krafter-kafka-exporter |
Kafka Exporter pod | monitoring | 9404 |
krafter-default-deny allows nothing, so for the pods it selects, whatever no other policy allows is dropped; it never outvotes an allow (How the Policies Combine).
A seventh, krafter-test-egress, gives pods labelled kates.io/test-pod=true egress to the cluster’s pods on 9090, 9091 and every listener port, DNS and the API server. It carries helm.sh/resource-policy: keep so a helm test against a reinstalled release still works.
Client ports come from the listeners, so an external listener, from the kafka.externalAccess preset or from kafka.listeners, adds its port (9094 by default) to the rules that need it, and externalAccess.allowedCidrs restricts the chart’s rule for the one named external. The chart renders no policy that selects another release’s pods — the operator’s own policy belongs to the strimzi-operator release (operatorPolicy, on by default), and Kafka UI, Connect and MirrorMaker 2 each carry their own.
Re-derive the list rather than trusting this table, since it follows the values chain you deploy with:
helm repo add seaweedfs https://seaweedfs.github.io/seaweedfs/helm
helm dependency build charts/kafka-cluster
helm template krafter charts/kafka-cluster -n kafka \
-f charts/kafka-cluster/values-platform.yaml \
| grep -A2 'kind: NetworkPolicy' | grep 'name:'On a running cluster, kubectl get networkpolicy -n kafka lists Strimzi’s generated policies beside these.
Testing Network Policies
Don’t trust that your network policies work — test them, with a test that can fail. A probe such as nc -z from a throwaway pod fails for many reasons that have nothing to do with policy: the image does not pull, admission rejects the pod, the name does not resolve, the broker is down. Reading every failure as “blocked” proves nothing. A failed probe is evidence of a policy drop only when:
- the same probe, run from a pod the policies admit, connects, so the brokers, DNS and the probe itself work;
- the probing pod resolved the bootstrap name and its connection timed out. A policy drops packets, whereas a refusal means something answered;
- the probing pod’s own namespace has no NetworkPolicy that could have dropped its egress.
make kafka-verify-policies runs that test (see Validating Policy Compliance). To run it by hand from the repository root, use the kates-tester image that the chart’s Helm tests run:
IMAGE=$(awk '$1 == "kubectl:" {gsub(/"/, "", $2); print $2; exit}' charts/kafka-cluster/values.yaml)
PROBE='getent hosts krafter-kafka-bootstrap.kafka.svc >/dev/null || { echo "no DNS"; exit; }
timeout 5 bash -c "exec 3<>/dev/tcp/krafter-kafka-bootstrap.kafka.svc/9092"; echo "exit $?"'
# Control: a pod the chart always admits. It must print "exit 0".
kubectl run np-control -n kafka --rm -i --restart=Never --image="$IMAGE" \
--labels=kates.io/test-pod=true -- bash -c "$PROBE"
# The probe: a namespace with no grant, and no NetworkPolicy of its own
kubectl get networkpolicy -n default
kubectl run np-probe -n default --rm -i --restart=Never --image="$IMAGE" -- bash -c "$PROBE"exit 0 from the probe means it reached the broker. exit 124 is a timeout, and counts as blocked only if the control printed exit 0 and default has no NetworkPolicy. Any other result proves nothing either way. On the charts as shipped, expect exit 0 from both pods until you close the listeners. If admission rejects the pods, use make kafka-verify-policies, whose probe pods carry a restricted security context and resource limits.
16.8 Container Security
The kafka-cluster chart hardens the broker and controller pods by default: every KafkaNodePool it renders carries the security contexts below in its pod template, and a pool’s podSecurityContext and containerSecurityContext values merge over them.
Security Contexts
Every container in the Kates platform runs with a hardened security context. These settings follow the Kubernetes Pod Security Standards (PSS) at the restricted level:
template:
kafkaContainer:
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
pod:
securityContext:
runAsNonRoot: true
fsGroup: 1001Each setting takes away one thing an attacker who gets into the container could use:
| Setting | Value | Purpose |
|---|---|---|
runAsNonRoot |
true | Prevents running as UID 0 — even if the container image specifies USER root |
readOnlyRootFilesystem |
true | No writes outside mounted volumes — prevents attackers from dropping malware |
allowPrivilegeEscalation |
false | Blocks setuid / setgid binaries — prevents privilege escalation within the container |
drop: ALL |
— | Removes all Linux capabilities — no raw sockets, no network admin, no filesystem mounts |
Quotas as Security
Per-user quotas prevent denial-of-service from misbehaving clients. Without quotas, a single runaway producer can saturate broker network bandwidth, disk I/O, and CPU — degrading performance for every other client on the cluster.
| User | Produce Rate | Consume Rate | CPU Share |
|---|---|---|---|
kafka-ui |
1 MB/s | 50 MB/s | 10% |
apicurio-registry |
10 MB/s | 20 MB/s | 15% |
kates-connect |
50 MB/s | 50 MB/s | 25% |
litmus-chaos |
Unlimited | Unlimited | Unlimited |
The litmus-chaos user intentionally has no quotas. Chaos experiments sometimes need to generate burst traffic to test broker behavior under pressure. Limiting the chaos agent would defeat the purpose.
16.9 Kyverno Policy Integration & Admission Control
The Kates platform integrates Kyverno as a Kubernetes-native policy engine for enforcing security standards via admission control. Think of Kyverno as a security guard at the door of your cluster — it inspects every resource creation and modification request and either fixes it, approves it, or rejects it.
graph LR
subgraph Admission Pipeline
Req["kubectl apply / Helm install"] --> Webhook["Kyverno Webhook"]
Webhook -->|"mutate"| Mutated["Patched Resource"]
Webhook -->|"validate"| Decision{"Pass / Fail"}
Decision -->|"Pass"| API["Kubernetes API"]
Decision -->|"Fail (Enforce)"| Reject["Admission Rejected"]
Decision -->|"Fail (Audit)"| API
end
Cluster Policies
The kates chart ships four ClusterPolicy resources. It renders them only when kyvernoPolicy.enabled=true in the Helm values and the Kyverno API exists, and the last two need a switch of their own besides (the kafka-cluster and kates-chaos charts ship additional policies of their own):
| Policy | Category | Severity | Description |
|---|---|---|---|
kates-pod-security-standards |
Pod Security | High | Mutates and validates restricted PSS: non-root, drop ALL capabilities, seccomp, read-only rootfs, no privilege escalation |
kates-workload-standards |
Best Practices | Medium | Requires standard labels, health probes, and pinned image tags on workloads |
kates-image-verification |
Supply Chain | Critical | Verifies Cosign image signatures from trusted registries. Off by default: needs kyvernoPolicy.cosign.enabled and a publicKey |
kates-generate-network-policies |
Network Security | Medium | Generates default-deny NetworkPolicies in namespaces created after it. Off by default: needs kyvernoPolicy.networkPolicyGeneration.enabled, which kates kyverno apply --with-netpol sets |
Pod Security Standards (Mutate + Validate)
The kates-pod-security-standards policy combines mutation (auto-patching) with validation (enforcement). This two-phase approach is deliberate: mutation silently fixes common mistakes so developers don’t have to remember every security setting, while validation catches anything mutation couldn’t fix.
Mutation rules (applied first):
| Rule | What It Patches |
|---|---|
mutate-run-as-non-root |
Sets runAsNonRoot: true, runAsUser: 1000, fsGroup: 1000 |
mutate-drop-capabilities |
Adds capabilities.drop: ["ALL"] to every container |
mutate-seccomp-profile |
Sets seccompProfile.type: RuntimeDefault |
mutate-disable-privilege-escalation |
Sets allowPrivilegeEscalation: false |
mutate-readonly-rootfs |
Sets readOnlyRootFilesystem: true |
Validation rules (enforced after mutation):
| Rule | Enforcement |
|---|---|
validate-non-root |
Rejects pods without runAsNonRoot: true |
validate-drop-capabilities |
Rejects containers that don’t drop ALL capabilities |
validate-seccomp-profile |
Requires RuntimeDefault or Localhost seccomp profile |
validate-no-privilege-escalation |
Rejects allowPrivilegeEscalation: true |
validate-readonly-rootfs |
Rejects writable root filesystems |
require-resource-limits |
Requires memory limits and cpu requests |
Mutation uses the +(key) conditional anchor syntax — values are injected only if the field is not already set. This prevents Kyverno from overwriting explicitly declared security contexts. If a developer explicitly sets runAsUser: 5000, Kyverno respects that choice.
Cosign Image Verification
The kates-image-verification policy enforces supply chain security by verifying container image signatures before admission. This protects against tampered images — even if an attacker pushes a malicious image to your registry, it won’t be admitted unless it carries a valid Cosign signature from your trusted key.
kyvernoPolicy:
cosign:
enabled: true
publicKey: |
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
-----END PUBLIC KEY-----
imagePatterns:
- "ghcr.io/bmscomp/*"When enabled, unsigned or tampered images from the specified registries are rejected at admission time. The policy also mutates image references to use digests (mutateDigest: true) for immutable deployments.
Automatic NetworkPolicy Generation
The kates-generate-network-policies policy is off by default: it renders only when both kyvernoPolicy.enabled and kyvernoPolicy.networkPolicyGeneration.enabled are true, which kates kyverno apply --with-netpol sets. Once it is on, Kyverno generates three NetworkPolicies in every namespace created afterwards:
default-deny-ingress— blocks all inbound trafficdefault-deny-egress— blocks all outbound trafficallow-dns-egress— allows DNS egress on port 53 (UDP and TCP) to any namespace
These policies are synchronized (synchronize: true) — Kyverno continuously reconciles them if they are manually deleted. This means even if someone accidentally (or maliciously) deletes the default-deny policies, they’re automatically recreated.
Namespace Exclusions
Certain system and infrastructure namespaces are excluded from automatic NetworkPolicy generation to prevent breaking core platform services:
| Excluded Namespace | Reason |
|---|---|
kube-system |
Core Kubernetes components |
kube-public |
Public cluster metadata |
kube-node-lease |
Node heartbeat Lease objects |
kyverno |
Kyverno’s own namespace |
strimzi-operator |
Strimzi Operator requires API server and Kafka namespace egress |
monitoring |
Prometheus must scrape metrics across all namespaces |
kafka |
Managed by dedicated kafka-cluster chart NetworkPolicies |
cert-manager |
Requires webhook ingress and ACME provider egress |
litmus |
Chaos runner pods need cross-namespace API access |
kates-detect-* |
Temporary CLI prober namespaces for latency and storage benchmarks |
Policy Exceptions
For development environments where strict enforcement would impede workflows, Kates supports PolicyException CRDs (Kyverno v2 API). These temporarily relax specific validation rules for designated namespaces without disabling the entire policy:
apiVersion: kyverno.io/v2
kind: PolicyException
metadata:
name: litmus-kates-pod-security-standards-exception
namespace: litmus
spec:
exceptions:
- policyName: kates-pod-security-standards
ruleNames:
- validate-readonly-rootfs
- validate-drop-capabilities
match:
any:
- resources:
namespaces: [litmus]
kinds: [Pod, Deployment, StatefulSet, ReplicaSet]Exceptions are configured via Helm values under kyvernoPolicy.policyExceptions and deployed only when policyExceptions.enabled=true.
Enforce vs Audit Modes
All Kyverno policies support two operational modes, controlled by the kyvernoPolicy.action Helm value:
| Mode | Behavior | Use Case |
|---|---|---|
Audit (default) |
Violations are logged in PolicyReport CRDs but pods are not blocked |
Initial rollout, observability, compliance auditing |
Enforce |
Non-compliant pods are rejected at admission | Production hardening, strict compliance |
Start with Audit mode. Review the PolicyReport violations with kates kyverno violations to see what would break, then switch to Enforce once you’ve resolved all legitimate violations. Jumping straight to Enforce on a running cluster is a recipe for cascading failures.
Switch modes at runtime using the Kates CLI (see below) or by patching the Helm values.
Kates CLI: kyverno Subcommands
The Kates CLI provides native Kyverno management through the kates kyverno command group:
# Show all ClusterPolicies with mode, readiness, and rule counts
kates kyverno status
# Show policy violations grouped by namespace and pod
kates kyverno violations
kates kyverno violations --namespace kafka
# Switch a policy to Enforce mode (blocks non-compliant pods)
kates kyverno enforce kates-pod-security-standards
# Switch a policy back to Audit mode (log-only)
kates kyverno audit kates-pod-security-standardsThe table adds the commands’ short aliases, where they have any, and what each one reads or changes:
| Command | Aliases | Description |
|---|---|---|
kates kyverno status |
st, list |
Lists all ClusterPolicies with mode, ready state, validate/mutate rule counts, and violation summary |
kates kyverno violations |
viol, fails |
Pretty-prints PolicyReport violations grouped by namespace and pod, with --namespace filter |
kates kyverno enforce <policy> |
— | Patches a ClusterPolicy’s validationFailureAction to Enforce |
kates kyverno audit <policy> |
— | Patches a ClusterPolicy’s validationFailureAction to Audit |
Cross-references: - For Kyverno installation instructions, see Installing Kafka with the kafka-cluster Helm Chart. - For Kyverno upgrade procedures, see Upgrade Playbook. - For a full index of Kyverno-related troubleshooting, see Troubleshooting Index.
16.10 Security Checklist
Use this checklist when auditing your deployment. Each item links to the section that explains how to verify it:
Validating Policy Compliance
To automate the verification of Kyverno policies, Strimzi operator health, and NetworkPolicy connectivity, use the built-in make target:
make kafka-verify-policiesThe target runs scripts/verify-kafka-policies.sh, which exits 0 when every check passes, 1 when one fails, and 2 when none failed but the network probe reached no verdict. make prints the script’s status as Error 1 or Error 2, and itself exits 2 for either. The script takes these steps, of which the first reports Kyverno rejections without counting them as a failure:
- Scan the
kafkanamespace events for any Kyverno rejections. - Find the Strimzi Cluster Operator in whichever namespaces it runs, and verify that each of them has an operator pod that is
Runningand Ready. An Evicted pod not yet garbage-collected, or a new pod stillPendingduring a rollout, does not fail the check. - Check the Kafka cluster CR status to ensure it successfully reached the
Readystate. - Run the probe from Testing Network Policies against the
plainlistener: first a control pod labelledkates.io/test-pod=truein thekafkanamespace, then a pod with no grant indefault. It reports blocked only for a timeout after a successful lookup, with the control connected and no NetworkPolicy indefault. A connection is a failure; any other result is inconclusive.
Set PROBE_NAMESPACE to probe from another namespace, for example make kafka-verify-policies PROBE_NAMESPACE=apps; KAFKA_NAMESPACE and KAFKA_CLUSTER select another cluster. On the charts as shipped, step 4 fails, because the listeners are open to every pod until you close them.
For deployment-level security details (Drain Cleaner, backup encryption), see Kafka Deployment Engineering.
Try it
Instead of walking the checklist by hand, let the platform grade itself:
# Grade the cluster's security posture with CIS-mapped checks
kates security audit
# See which Kyverno policies are active, and whether they Audit or Enforce
kates kyverno status
# List any workloads currently violating policy
kates kyverno violations --namespace kafkaExpect a graded report organized by category — authentication, transport security, policy engine — with a remediation section for every check that isn’t a PASS; on a fresh deployment the Policy Enforcement check warns until you switch policies from Audit to Enforce.
16.11 Summary
- Every listener authenticates, but only some encrypt:
plain(9092) uses SCRAM-SHA-512 without TLS so performance baselines isolate Kafka throughput from encryption cost — production traffic belongs ontls(9093) orexternal(9094). - Authorization is deny-by-default: each service gets its own
KafkaUserwithpatternType: prefixACLs and quotas, and onlykates-backend, the Kates API’s user, holds superUser status. - SCRAM passwords live in base64-encoded — not encrypted — Kubernetes Secrets, so RBAC on Secrets in the
kafkaandkatesnamespaces is the wall around your credentials. Kates reads its copy of thekates-backendpassword once, at startup, and nothing keeps that copy in step, so a rotation is rotate, re-copy, restart. - Kyverno enforces restricted Pod Security Standards in two phases — mutation silently patches missing settings, validation rejects what mutation can’t fix — and starts in
Auditmode so you can review violations before switching toEnforce. - As shipped, every pod can reach the client listeners; giving each listener
networkPolicyPeersmakesnetworkPolicy.clientsthe allow list wherever the chart’s policies render. kates security auditgrades the whole posture A–F against CIS-mapped checks, whilemake kafka-verify-policieschecks Kyverno, the Strimzi operator and the Kafka CR, and probes whether a pod with no grant can reach the brokers.
With the security layers in place for a single team, Multi-Tenancy shows how to share the same cluster across many services and teams without interference.