---
title: "Enforce tenancy with admission webhooks"
description: "Restrict which topic names a namespace may claim with a KafkaCluster tenancy policy, and reject duplicate topic identities at admission."
url: https://monedula.dev/flock/docs/gitops/how-to/enforce-tenancy/
---

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](/flock/docs/gitops/how-to/configure-the-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`](/flock/docs/gitops/how-to/configure-the-operator/).

## 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`:

```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

```bash
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`:

```yaml
apiVersion: gitops.monedula.dev/v1alpha1
kind: KafkaTopic
metadata:
  name: tenancy-bad
spec:
  clusterRef:
    name: tenant
  topicName: forbidden.topic
  partitions: 1
```

```bash
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`:

```yaml
apiVersion: gitops.monedula.dev/v1alpha1
kind: KafkaTopic
metadata:
  name: dup-orders-a
spec:
  clusterRef:
    name: shared
  topicName: dup.orders
  partitions: 1
```

```yaml
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:

```bash
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](/flock/docs/gitops/how-to/configure-the-operator/). 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

<Tabs group="enforce-tenancy-check" labels={['Webhook on', 'Webhook off']} label="Admission webhook">

```bash
kubectl get kafkatopic
```

```bash
kubectl get kafkatopic tenancy-bad -o jsonpath='{.status.conditions}'
```

</Tabs>

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`.

## Related

- The [KafkaCluster reference](/flock/docs/gitops/reference/resources/kafka-cluster/) lists every `tenancy` field.
- Runnable versions: [scenarios/15-tenancy-denied](https://github.com/monedula-dev/monedula-gitops/tree/main/scenarios/15-tenancy-denied) and [scenarios/03-webhook-reject-duplicate-identity](https://github.com/monedula-dev/monedula-gitops/tree/main/scenarios/03-webhook-reject-duplicate-identity)