Monedula
monedula-gitops docs menu

Enforce tenancy with admission webhooks

Restrict which topic names a namespace may claim with a KafkaCluster tenancy policy, and reject duplicate topic identities at admission.

How-to guides 14 of 18

5 min read

Updated 6 October 2026

Markdown

On this page

A tenancy policy on a KafkaCluster decides which namespaces may use the cluster and which topic name prefixes each namespace may claim. Tenancy is an operator control: the CLI checks only the shape of a tenancy block.

Before you start

  • The operator with the admission webhook enabled (--set webhook.enabled=true). The webhook needs a serving certificate, issued by cert-manager by default or brought by you, as Configure the Kubernetes operator shows.
  • Control over the KafkaCluster resources. A team that can create its own KafkaCluster for the same brokers bypasses the policy, so keep every KafkaCluster in a namespace that tenants cannot write to, with the Helm value clusterNamespace.

Steps

1. Add a tenancy policy to the KafkaCluster

This KafkaCluster lets only the namespace monedula-e2e reference it, and restricts that namespace to topic names that start with payments.. Save it as manifests/cluster.yaml:

apiVersion: gitops.monedula.dev/v1alpha1
kind: KafkaCluster
metadata:
  name: tenant
spec:
  bootstrapServers: host.docker.internal:9096
  auth:
    mechanism: SCRAM-SHA-512
    scram:
      username:
        valueFrom:
          secretKeyRef:
            name: kafka-credentials
            key: kafka-username
      password:
        valueFrom:
          secretKeyRef:
            name: kafka-credentials
            key: kafka-password
  tenancy:
    allowedNamespaces:
      - monedula-e2e
    topicPrefixes:
      - namespaces:
          - monedula-e2e
        prefixes:
          - payments.

The bootstrap address, the Secret kafka-credentials and the namespace monedula-e2e come from the runnable scenario’s test environment; use your own, and run the kubectl commands on this page in the tenant namespace.

The policy has two levels:

  • allowedNamespaces lists glob patterns, such as team-payments-*, for the namespaces whose resources of any kind may reference this cluster. When it is empty, any namespace may.
  • Each topicPrefixes rule maps namespace glob patterns to allowed name prefixes. A namespace that matches at least one rule is prefix-restricted: it may only manage names that start with one of the prefixes of all rules it matches. A namespace that matches no rule is limited only by allowedNamespaces.

For a prefix-restricted namespace, the prefixes apply to more than topic names:

  • Consumer group names reuse the topic prefixes: the groups in a topic’s access block, group rules in a KafkaAccessPolicy and Group resources of a KafkaRoleBinding must match them too. KafkaAccessPolicy rules are checked whether their pattern is literal or prefixed.
  • Grants that a name prefix cannot scope are denied: KafkaAccessPolicy rules on the cluster, transactionalId or delegationToken resource, cluster-scoped role bindings and role-binding resources other than Topic and Group. Allowing them would let a team escalate past its prefix, for example with Alter on the cluster resource.
  • KafkaQuota and KafkaUser get no prefix check, because quota entities and usernames have no prefix structure. Only allowedNamespaces limits them, so any allowed namespace may declare a quota or a user for any entity; a second claim on an entity that another team already declares is a duplicate identity, handled as in step 4.

2. Apply the cluster first

kubectl apply -f manifests/cluster.yaml

The webhook reads the tenancy policy from the KafkaCluster when it checks a topic. A topic whose cluster does not exist yet passes admission, and the operator checks it when it reconciles.

3. Try a topic outside the prefix

This topic in the restricted namespace claims the name forbidden.topic. Save it as manifests/topic.yaml:

apiVersion: gitops.monedula.dev/v1alpha1
kind: KafkaTopic
metadata:
  name: tenancy-bad
spec:
  clusterRef:
    name: tenant
  topicName: forbidden.topic
  partitions: 1
kubectl apply -f manifests/topic.yaml

The webhook rejects the request with a message that names the topic name, the namespace and the allowed prefixes, here payments..

The webhook checks tenancy for KafkaTopic only. A violating resource of another kind, or any violating topic when the webhook is off, is created and then rejected by the operator: it gets the phase Error and the condition ValidationFailed=True with the reason TenancyDenied, and the operator does not touch Kafka for it until its spec, its namespace or the tenancy policy changes. Tenancy never blocks a deletion. A denied KafkaTopic, KafkaAccessPolicy or KafkaRoleBinding still counts as an owner of the ACLs and role bindings it declares, so the prunes of other resources on the cluster leave them in place.

4. Reject a duplicate topic identity

A topic’s identity is its cluster and its topicName, and only one KafkaTopic may claim it. These two topics both claim dup.orders on the cluster shared. Save them as manifests/a.yaml and manifests/b.yaml:

apiVersion: gitops.monedula.dev/v1alpha1
kind: KafkaTopic
metadata:
  name: dup-orders-a
spec:
  clusterRef:
    name: shared
  topicName: dup.orders
  partitions: 1
apiVersion: gitops.monedula.dev/v1alpha1
kind: KafkaTopic
metadata:
  name: dup-orders-b
spec:
  clusterRef:
    name: shared
  topicName: dup.orders
  partitions: 1

Apply the first, then the second:

kubectl apply -f manifests/a.yaml
kubectl apply -f manifests/b.yaml

The first is created. The webhook rejects the second with a message that contains topic identity (cluster, topicName) must be unique and names the topic that already holds the identity. Duplicate KafkaQuota, KafkaRoleBinding and KafkaUser identities are rejected the same way.

Without the webhook, the operator detects the duplicate when it reconciles. The older resource, by creationTimestamp and then by namespace and name, wins and keeps working. The newer one gets ValidationFailed=True with the reason DuplicateIdentity, Ready=False and a Warning event, and never touches Kafka. Delete the newer resource to resolve it. If you delete the older one instead, the newer takes over at its next resync. Deleting either of two duplicate KafkaUser or KafkaQuota resources leaves the credential or quota in place for the other, with an OrphanedToCoClaimant event.

The CRDs keep identity fields immutable with or without the webhook: a topic’s topicName and clusterRef, a policy’s clusterRef, a quota’s entity, a role binding’s clusterRef, principal, role and scope.type, and a user’s username and clusterRef. Renaming a topic or moving it to another cluster is rejected.

Check it worked

kubectl get kafkatopic
kubectl get kafkatopic tenancy-bad -o jsonpath='{.status.conditions}'

With the webhook on, dup-orders-a is listed, and neither tenancy-bad nor dup-orders-b exists, because a rejected request is never stored. With the webhook off, tenancy-bad exists with ValidationFailed set to True and the reason TenancyDenied, and Kafka has no topic forbidden.topic.