Compare commits

..

4 Commits

Author SHA1 Message Date
Stéphane Tailland e6699912a5 Deploy a cluster, not an estate
The repo had accreted one estate's accounts, topics and prose. An application's
account and its topics belong to the application, whose own repo says what it
may read and write — so that adding or removing the application adds or removes
its access.

What is left is the cluster, the admin and smoke-test accounts, the
cross-namespace credentials store, and the pattern to copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 08:43:14 +02:00
Stéphane Tailland 979c9da6c0 Document the topics, the consumer account and the credentials store
The README described the cluster as it was before events existed: no declared
topics, no consumer, and no way for another namespace to reach a password
Strimzi keeps here.

The events themselves are specified by their producer, not here — this repo
owns the cluster, not the contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 08:36:48 +02:00
Stéphane Tailland 9e0f908f19 Add the Approvisionnement consumer account
Read on one topic and one consumer-group prefix. It cannot see sales or
payments, and it cannot write, so a compromised workflow can raise no event
another agent would act on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 07:46:24 +02:00
Stéphane Tailland 5770f78c99 Declare the classic-models topics and a credentials store
Topics are declared rather than auto-created: the application account has no
Create ACL, so a producer would have been denied, and an auto-created topic
takes broker defaults nobody chose.

The credentials store lets an application namespace read its own KafkaUser
password and the cluster CA from the kafka namespace, so the password stays
generated and rotatable by Strimzi instead of being copied by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 07:34:55 +02:00
4 changed files with 174 additions and 37 deletions
+8 -5
View File
@@ -59,28 +59,30 @@ help:
# ── Cluster lifecycle ───────────────────────────────────────────────────────── # ── Cluster lifecycle ─────────────────────────────────────────────────────────
deploy: deploy:
@echo "==> [1/5] Creating namespace '$(NAMESPACE)'" @echo "==> [1/6] Creating namespace '$(NAMESPACE)'"
@kubectl create namespace $(NAMESPACE) --dry-run=client -o yaml | kubectl apply -f - @kubectl create namespace $(NAMESPACE) --dry-run=client -o yaml | kubectl apply -f -
@echo "==> [2/5] Installing Strimzi operator" @echo "==> [2/6] Installing Strimzi operator"
@helm repo add strimzi https://strimzi.io/charts/ 2>/dev/null || true @helm repo add strimzi https://strimzi.io/charts/ 2>/dev/null || true
@helm repo update strimzi 2>/dev/null @helm repo update strimzi 2>/dev/null
@helm upgrade --install strimzi-kafka-operator strimzi/strimzi-kafka-operator \ @helm upgrade --install strimzi-kafka-operator strimzi/strimzi-kafka-operator \
--namespace $(NAMESPACE) \ --namespace $(NAMESPACE) \
--set watchAnyNamespace=false \ --set watchAnyNamespace=false \
--wait --timeout 5m --wait --timeout 5m
@echo "==> [3/5] Waiting for Strimzi CRDs" @echo "==> [3/6] Waiting for Strimzi CRDs"
@for crd in kafkas.kafka.strimzi.io kafkanodepools.kafka.strimzi.io kafkausers.kafka.strimzi.io; do \ @for crd in kafkas.kafka.strimzi.io kafkanodepools.kafka.strimzi.io kafkausers.kafka.strimzi.io; do \
until kubectl get crd "$$crd" -o jsonpath='{.status.conditions[?(@.type=="Established")].status}' 2>/dev/null | grep -q True; do sleep 2; done; \ until kubectl get crd "$$crd" -o jsonpath='{.status.conditions[?(@.type=="Established")].status}' 2>/dev/null | grep -q True; do sleep 2; done; \
echo " $$crd ready"; \ echo " $$crd ready"; \
done done
@echo "==> [4/5] Deploying Kafka cluster (KRaft, SASL_SSL, SCRAM-SHA-512)" @echo "==> [4/6] Deploying Kafka cluster (KRaft, SASL_SSL, SCRAM-SHA-512)"
@kubectl apply -f $(SCRIPT_DIR)kafka.yaml @kubectl apply -f $(SCRIPT_DIR)kafka.yaml
@kubectl wait kafka/$(KAFKA_NAME) --for=condition=Ready --timeout=10m -n $(NAMESPACE) @kubectl wait kafka/$(KAFKA_NAME) --for=condition=Ready --timeout=10m -n $(NAMESPACE)
@echo "==> [5/5] Deploying KafkaUsers" @echo "==> [5/6] Deploying KafkaUsers"
@kubectl apply -f $(SCRIPT_DIR)kafka-users.yaml @kubectl apply -f $(SCRIPT_DIR)kafka-users.yaml
@for user in kafka-admin kafka-client; do \ @for user in kafka-admin kafka-client; do \
kubectl wait kafkauser/$$user --for=condition=Ready --timeout=120s -n $(NAMESPACE); \ kubectl wait kafkauser/$$user --for=condition=Ready --timeout=120s -n $(NAMESPACE); \
done done
@echo "==> [6/6] Deploying the credentials store"
@kubectl apply -f $(SCRIPT_DIR)credentials-access.yaml
@echo "" @echo ""
@echo "Cluster ready. Bootstrap: $(BOOTSTRAP)" @echo "Cluster ready. Bootstrap: $(BOOTSTRAP)"
@@ -112,6 +114,7 @@ upgrade:
teardown: teardown:
@echo "==> Deleting KafkaUsers and Kafka cluster" @echo "==> Deleting KafkaUsers and Kafka cluster"
@kubectl delete -f $(SCRIPT_DIR)credentials-access.yaml --ignore-not-found
@kubectl delete -f $(SCRIPT_DIR)kafka-users.yaml --ignore-not-found @kubectl delete -f $(SCRIPT_DIR)kafka-users.yaml --ignore-not-found
@kubectl delete -f $(SCRIPT_DIR)kafka.yaml --ignore-not-found @kubectl delete -f $(SCRIPT_DIR)kafka.yaml --ignore-not-found
@echo "==> Waiting for pods to terminate" @echo "==> Waiting for pods to terminate"
+63 -6
View File
@@ -40,17 +40,71 @@ kafka-kafka-bootstrap.kafka.svc.cluster.local:9093
|---|---|---| |---|---|---|
| `kafka-admin` | `kafka-admin` | Super user — unrestricted access | | `kafka-admin` | `kafka-admin` | Super user — unrestricted access |
| `kafka-client` | `kafka-client` | Application user — Read/Write all topics and groups | | `kafka-client` | `kafka-client` | Application user — Read/Write all topics and groups |
| `classic-models-api` | `classic-models-api` | Producer for `classic-models.*` — **Write and Describe only**, no Read |
`classic-models-api` publishes domain events and consumes nothing, so it is These two are all this repo declares. **An application's account is not added
write-only and prefix-scoped. A producer that can also read every topic is a here** — it belongs to the application, whose own repo says what it may read and
producer that exfiltrates every other domain's events if it is ever write, so that adding or removing the application adds or removes its access.
compromised. This repo deploys a cluster and holds no opinion about who uses it.
`kafka-users.yaml` carries the shape to copy. Grant **one direction, on one
prefix**: a producer that can also read every topic exfiltrates every other
application's data if it is ever compromised, and a consumer that can write can
raise an event someone else acts on.
`kafka-client` is the wide-open template — useful for a smoke test, and for
nothing that runs unattended.
Strimzi stores credentials in Kubernetes secrets. Each secret contains two keys: Strimzi stores credentials in Kubernetes secrets. Each secret contains two keys:
- `password` — the SCRAM password - `password` — the SCRAM password
- `sasl.jaas.config` — the ready-to-use JAAS config string - `sasl.jaas.config` — the ready-to-use JAAS config string
### Topics
This repo declares none, for the same reason it declares no application account:
a topic belongs to whoever publishes on it, and its partition count, retention
and cleanup policy are that application's decisions, not the broker's.
Declare them as `KafkaTopic` CRs from the producer's own repo, **in this
namespace** — Strimzi runs with `watchAnyNamespace=false`, so one created
anywhere else is ignored, silently.
Declare them rather than relying on auto-creation: an application account has no
`Create` ACL, so a producer would simply be denied, and an auto-created topic
takes broker defaults nobody chose. Partition count is the ceiling on consumer
parallelism and **cannot be lowered later**.
`make topic-create TOPIC=… PARTITIONS=…` exists for a scratch topic, not for
anything an application depends on.
### Getting credentials into another namespace
A Secret cannot be mounted across namespaces, and Strimzi writes a KafkaUser's
password into **this** one. `credentials-access.yaml` therefore declares a
`ClusterSecretStore` named `kafka-credentials` (External Secrets, `kubernetes`
provider) backed by a ServiceAccount that can read Secrets here and nowhere
else. An application namespace then pulls its own password and the cluster CA
with an `ExternalSecret`:
```yaml
secretStoreRef:
kind: ClusterSecretStore
name: kafka-credentials
data:
- secretKey: password
remoteRef: {key: <kafkauser-name>, property: password}
- secretKey: ca.crt
remoteRef: {key: kafka-cluster-ca-cert, property: ca.crt}
```
The password stays generated and rotatable by Strimzi; the copy follows. The
alternative — copying it by hand — drifts the first time the user is
regenerated.
**The trade, stated:** any namespace allowed to create an `ExternalSecret`
against this store can pull **any** Kafka account's credentials. Acceptable with
one operator; on a shared cluster, narrow the Role with `resourceNames` and run
one store per application.
### TLS ### TLS
Strimzi generates and manages its own internal CA. The cluster CA certificate is stored in: Strimzi generates and manages its own internal CA. The cluster CA certificate is stored in:
@@ -125,6 +179,7 @@ The target performs the following steps:
3. Waits for the Strimzi CRDs to be fully established 3. Waits for the Strimzi CRDs to be fully established
4. Applies `kafka.yaml` (KafkaNodePool + Kafka CRs) and waits for `Ready` 4. Applies `kafka.yaml` (KafkaNodePool + Kafka CRs) and waits for `Ready`
5. Applies `kafka-users.yaml` (KafkaUser CRs) and waits for `Ready` 5. Applies `kafka-users.yaml` (KafkaUser CRs) and waits for `Ready`
6. Applies `credentials-access.yaml` (the cross-namespace credentials store)
Expected duration: **4–6 minutes** on a single-node k3s cluster. Expected duration: **4–6 minutes** on a single-node k3s cluster.
@@ -223,7 +278,9 @@ The job is cleaned up automatically after 1 minute (`ttlSecondsAfterFinished: 60
## Adding a new user ## Adding a new user
Create a new `KafkaUser` manifest following the `kafka-client` pattern in `kafka-users.yaml`, then apply it: Copy the pattern documented at the bottom of `kafka-users.yaml` into the
application's **own** repo, then apply it — into this namespace, since Strimzi
watches no other:
```bash ```bash
kubectl apply -f kafka-users.yaml kubectl apply -f kafka-users.yaml
+68
View File
@@ -0,0 +1,68 @@
---
# Letting an application namespace read its own Kafka credentials.
#
# Strimzi writes a KafkaUser's SCRAM password into a Secret in *this* namespace,
# and a Secret cannot be mounted across namespaces. The alternatives are to copy
# the password by hand (it then drifts the first time the user is regenerated)
# or to put it in Vault (a second copy of a credential the cluster already
# owns, kept in sync by nobody).
#
# Instead External Secrets reads it here and materialises it there. The password
# stays generated by, and rotatable by, Strimzi; the copy follows.
#
# The ServiceAccount below can read Secrets in this namespace ONLY, which
# includes every KafkaUser's password. That is the trade: any namespace allowed
# to create an ExternalSecret against this store can pull any Kafka account's
# credentials. Acceptable in a lab with one operator; in a shared cluster,
# narrow the Role with resourceNames and run one store per application.
apiVersion: v1
kind: ServiceAccount
metadata:
name: kafka-credentials-reader
namespace: kafka
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: kafka-credentials-reader
namespace: kafka
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: kafka-credentials-reader
namespace: kafka
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: kafka-credentials-reader
subjects:
- kind: ServiceAccount
name: kafka-credentials-reader
namespace: kafka
---
# External Secrets authenticates as that ServiceAccount rather than as itself,
# so what the store can reach is bounded by the Role above and visible in RBAC
# rather than in the operator's own permissions.
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: kafka-credentials
spec:
provider:
kubernetes:
remoteNamespace: kafka
server:
caProvider:
type: ConfigMap
name: kube-root-ca.crt
key: ca.crt
namespace: kafka
auth:
serviceAccount:
name: kafka-credentials-reader
namespace: kafka
+35 -26
View File
@@ -52,30 +52,39 @@ spec:
operations: operations:
- Describe - Describe
- Write - Write
---
# The classic-models API, which publishes domain events and consumes nothing.
#
# Write-only and topic-scoped: a producer that can also read every topic is a
# producer that can exfiltrate every other domain's events if it is ever
# compromised. Describe is needed for metadata; it does not grant reads.
apiVersion: kafka.strimzi.io/v1
kind: KafkaUser
metadata:
name: classic-models-api
namespace: kafka
labels:
strimzi.io/cluster: kafka
spec:
authentication:
type: scram-sha-512
authorization:
type: simple
acls:
- resource:
type: topic
name: classic-models.
patternType: prefix
operations:
- Write
- Describe
# --- adding an application account ----------------------------------------
#
# Do not add one here. An account belongs to the application that uses it: its
# own repo says what it may read and write, and adding or removing the
# application adds or removes its access. This repo deploys a cluster and holds
# no opinion about who uses it.
#
# Create the CR from the application's own repo, in **this** namespace — Strimzi
# runs with `watchAnyNamespace=false`, so a KafkaUser anywhere else is ignored,
# silently. Copy the shape below and cut it down to what the application does:
#
# apiVersion: kafka.strimzi.io/v1
# kind: KafkaUser
# metadata:
# name: <application>
# namespace: kafka
# labels:
# strimzi.io/cluster: kafka
# spec:
# authentication:
# type: scram-sha-512
# authorization:
# type: simple
# acls:
# - resource: {type: topic, name: <prefix>, patternType: prefix}
# operations: [Write, Describe] # a producer
# - resource: {type: group, name: <prefix>, patternType: prefix}
# operations: [Read, Describe] # a consumer's group
#
# A producer that can also read every topic exfiltrates every other
# application's data if it is ever compromised, and a consumer that can write
# can raise an event someone else acts on. Grant one direction, on one prefix.
#
# `kafka-client` above is the wide-open template: useful for a smoke test, and
# for nothing that runs unattended.