conduktor.io ↗

Multi-Tenant SaaS

Tenant prefixes, quota tiers, and chargeback labels for shared clusters.

Why this bundle

Tenant prefixes, quota tiers, and chargeback labels for shared clusters.

Eighteen policies that pin every topic to a tenant-id prefix, force matching tenant-id labels, cap retention and partitions per tier (free, paid, enterprise), require cost-center and quota-tier labels for chargeback, and confine connectors and ApplicationGroups to a single tenant. Closes the cross-tenant exfiltration paths that break noisy-neighbour isolation.

Apply the whole bundle

One concatenated YAML stream with every ResourcePolicy in this bundle. Copy, save, apply.

# All policies in the Multi-Tenant SaaS bundle (18 resources)
# Save as bundle-multi-tenant.yaml then: conduktor apply -f bundle-multi-tenant.yaml
# Each ResourcePolicy must still be linked via Application(Instance).spec.policyRef
# or KafkaCluster.spec.policiesRef to take effect.
---
apiVersion: self-serve/v1
kind: ResourcePolicy
metadata:
  name: topic-name-convention
spec:
  targetKind: Topic
  description: Topic names must follow <env>.<domain>.<entity>.<version>
  rules:
    - condition: metadata.name.matches("^(dev|staging|prod)\\.[a-z0-9-]+\\.[a-z0-9-]+\\.v[0-9]+$")
      errorMessage: "Topic name must match <env>.<domain>.<entity>.<version> (e.g. prod.orders.placed.v1)"
---
apiVersion: self-serve/v1
kind: ResourcePolicy
metadata:
  name: consumer-group-prefix
spec:
  targetKind: ApplicationGroup
  description: Consumer-group / ApplicationGroup names must be <team>.<app>.cg
  rules:
    - condition: metadata.name.matches("^[a-z][a-z0-9-]+\\.[a-z0-9-]+\\.cg$")
      errorMessage: "Consumer group must be <team>.<app>.cg (e.g. orders.fraud-detector.cg)"
---
apiVersion: self-serve/v1
kind: ResourcePolicy
metadata:
  name: partition-count-bounds
spec:
  targetKind: Topic
  description: Topics must have between 1 and 200 partitions
  rules:
    - condition: spec.partitions >= 1 && spec.partitions <= 200
      errorMessage: "Partitions must be between 1 and 200 (request an override for outliers)"
---
apiVersion: self-serve/v1
kind: ResourcePolicy
metadata:
  name: min-replication-factor
spec:
  targetKind: Topic
  description: Production topics must have replication factor >= 3
  rules:
    - condition: '!metadata.name.startsWith("prod.") || spec.replicationFactor >= 3'
      errorMessage: "Production topics must have replication factor >= 3"
---
apiVersion: self-serve/v1
kind: ResourcePolicy
metadata:
  name: schema-required-non-internal
spec:
  targetKind: Topic
  description: Non-internal topics must declare a schema subject via labels.schema-subject
  rules:
    - condition: 'metadata.name.startsWith("__") || metadata.name.startsWith("_") || ("schema-subject" in metadata.labels && size(metadata.labels["schema-subject"]) > 0)'
      errorMessage: "Non-internal topics must set label schema-subject=<subject-name> (or be Schema-Registry-governed)"
---
apiVersion: self-serve/v1
kind: ResourcePolicy
metadata:
  name: no-wildcard-acl-prod
spec:
  targetKind: ApplicationGroup
  description: ApplicationGroups touching prod.* resources cannot use wildcard LITERAL resource patterns
  rules:
    - condition: 'spec.permissions.all(p, !(p.name.startsWith("prod.") || p.name == "*") || (p.patternType != "LITERAL" || p.name != "*"))'
      errorMessage: "Wildcard LITERAL resource is not allowed on production resources"
---
apiVersion: self-serve/v1
kind: ResourcePolicy
metadata:
  name: topic-owner-label-required
spec:
  targetKind: Topic
  description: every topic must carry owner (email) and data-criticality (C0..C3) labels
  rules:
    - condition: 'has(metadata.labels.owner) && metadata.labels.owner.matches("^[a-z0-9._-]+@[a-z0-9.-]+\\.[a-z]{2,}$") && "data-criticality" in metadata.labels && metadata.labels["data-criticality"] in ["C0", "C1", "C2", "C3"]'
      errorMessage: "metadata.labels.owner (email) and metadata.labels[\"data-criticality\"] (C0..C3) are required — unowned topics become stale and unpageable"
---
apiVersion: self-serve/v1
kind: ResourcePolicy
metadata:
  name: topic-domain-prefix-matches-app
spec:
  targetKind: Topic
  description: topic name must start with "<domain>." matching metadata.labels.domain
  rules:
    - condition: 'has(metadata.labels.domain) && metadata.name.startsWith(metadata.labels.domain + ".")'
      errorMessage: "topic name must start with \"<domain>.\" matching metadata.labels.domain (keeps naming, ACL prefix and chargeback in sync)"
---
apiVersion: self-serve/v1
kind: ResourcePolicy
metadata:
  name: applicationgroup-no-wildcard-write
spec:
  targetKind: ApplicationGroup
  description: wildcard (name="*") permissions cannot include write/create/delete on topics or connectors
  rules:
    - condition: 'spec.permissions.all(p, p.name != "*" || !p.permissions.exists(perm, perm in ["topicProduce", "topicCreate", "topicDelete", "kafkaConnectCreate", "kafkaConnectDelete"]))'
      errorMessage: "wildcard (name=\"*\") permissions cannot include write/create/delete on topics or connectors — scope by prefix instead"
---
apiVersion: self-serve/v1
kind: ResourcePolicy
metadata:
  name: applicationgroup-no-subject-wildcard-read
spec:
  targetKind: ApplicationGroup
  description: SUBJECT permissions must be prefix-scoped (patternType=PREFIXED, name!="*")
  rules:
    - condition: 'spec.permissions.all(p, p.resourceType != "SUBJECT" || (p.name != "*" && p.patternType == "PREFIXED"))'
      errorMessage: "SUBJECT permissions must be prefix-scoped (patternType=PREFIXED, name!=\"*\") — schema field names leak PII structure"
---
apiVersion: self-serve/v1
kind: ResourcePolicy
metadata:
  name: tenant-id-topic-prefix
spec:
  targetKind: Topic
  description: Topic names must begin with t-<6 alphanum>. (e.g. t-ab12cd.prod.orders.placed.v1)
  rules:
    - condition: metadata.name.matches("^t-[a-z0-9]{6}\\..*")
      errorMessage: "Topic name must start with a tenant prefix t-<6 alphanum>. (e.g. t-ab12cd.prod.orders.placed.v1)"
---
apiVersion: self-serve/v1
kind: ResourcePolicy
metadata:
  name: tenant-label-consistency
spec:
  targetKind: Topic
  description: metadata.labels.tenant-id must equal the topic's t-<id> prefix
  rules:
    - condition: '"tenant-id" in metadata.labels && size(metadata.labels["tenant-id"]) == 8 && metadata.name.startsWith(metadata.labels["tenant-id"] + ".")'
      errorMessage: "metadata.labels.tenant-id must be set and equal the tenant prefix of the topic name (e.g. labels.tenant-id=t-ab12cd for topic t-ab12cd.prod.orders.placed.v1)"
---
apiVersion: self-serve/v1
kind: ResourcePolicy
metadata:
  name: tenant-cost-center-required
spec:
  targetKind: Topic
  description: Every topic must carry cost-center and quota-tier labels (chargeback / FinOps)
  rules:
    - condition: '"cost-center" in metadata.labels && size(metadata.labels["cost-center"]) > 0'
      errorMessage: "metadata.labels.cost-center is required for chargeback attribution"
    - condition: '"quota-tier" in metadata.labels && metadata.labels["quota-tier"] in ["bronze", "silver", "gold"]'
      errorMessage: "metadata.labels.quota-tier must be one of bronze, silver, gold"
---
apiVersion: self-serve/v1
kind: ResourcePolicy
metadata:
  name: tenant-retention-by-tier
spec:
  targetKind: Topic
  description: retention.ms is capped per tenant-tier (free 7d, paid 30d, enterprise 90d)
  rules:
    - condition: '"tenant-tier" in metadata.labels && metadata.labels["tenant-tier"] in ["free", "paid", "enterprise"]'
      errorMessage: "metadata.labels.tenant-tier must be one of free, paid, enterprise"
    - condition: '!("retention.ms" in spec.configs) || (int(string(spec.configs["retention.ms"])) != -1 && ((metadata.labels["tenant-tier"] == "free" && int(string(spec.configs["retention.ms"])) <= 604800000) || (metadata.labels["tenant-tier"] == "paid" && int(string(spec.configs["retention.ms"])) <= 2592000000) || (metadata.labels["tenant-tier"] == "enterprise" && int(string(spec.configs["retention.ms"])) <= 7776000000)))'
      errorMessage: "retention.ms exceeds the cap for this tenant-tier (free 7d, paid 30d, enterprise 90d) and infinite retention is never allowed"
---
apiVersion: self-serve/v1
kind: ResourcePolicy
metadata:
  name: tenant-partition-budget
spec:
  targetKind: Topic
  description: Per-topic partition count is capped by tenant-tier (free 6, paid 24, enterprise 50)
  rules:
    - condition: '"tenant-tier" in metadata.labels && metadata.labels["tenant-tier"] in ["free", "paid", "enterprise"]'
      errorMessage: "metadata.labels.tenant-tier must be one of free, paid, enterprise"
    - condition: '(metadata.labels["tenant-tier"] == "free" && spec.partitions <= 6) || (metadata.labels["tenant-tier"] == "paid" && spec.partitions <= 24) || (metadata.labels["tenant-tier"] == "enterprise" && spec.partitions <= 50)'
      errorMessage: "Partition count exceeds tenant-tier budget (free 6, paid 24, enterprise 50) — request a tier upgrade or split the workload"
---
apiVersion: self-serve/v1
kind: ResourcePolicy
metadata:
  name: tenant-min-insync-by-tier
spec:
  targetKind: Topic
  description: min.insync.replicas floor scales with tenant-tier (free 1, paid 2, enterprise 2 with RF >= 3)
  rules:
    - condition: '"tenant-tier" in metadata.labels && metadata.labels["tenant-tier"] in ["free", "paid", "enterprise"]'
      errorMessage: "metadata.labels.tenant-tier must be one of free, paid, enterprise"
    - condition: '"min.insync.replicas" in spec.configs && ((metadata.labels["tenant-tier"] == "free" && int(string(spec.configs["min.insync.replicas"])) >= 1) || (metadata.labels["tenant-tier"] == "paid" && int(string(spec.configs["min.insync.replicas"])) >= 2) || (metadata.labels["tenant-tier"] == "enterprise" && int(string(spec.configs["min.insync.replicas"])) >= 2 && spec.replicationFactor >= 3))'
      errorMessage: "min.insync.replicas (and RF for enterprise) below the tenant-tier floor (free>=1, paid>=2, enterprise>=2 with RF>=3)"
---
apiVersion: self-serve/v1
kind: ResourcePolicy
metadata:
  name: tenant-connector-topic-scope
spec:
  targetKind: Connector
  description: Connector's topics / topics.regex must be confined to its own tenant prefix
  rules:
    - condition: '"tenant-id" in metadata.labels && size(metadata.labels["tenant-id"]) == 8 && metadata.labels["tenant-id"].matches("^t-[a-z0-9]{6}$")'
      errorMessage: "Connector must carry metadata.labels.tenant-id matching t-<6 alphanum>"
    - condition: '("topics" in spec.config && spec.config["topics"].startsWith(metadata.labels["tenant-id"] + ".")) || ("topics.regex" in spec.config && spec.config["topics.regex"].startsWith("^" + metadata.labels["tenant-id"] + "\\."))'
      errorMessage: "Connector spec.config.topics must start with <tenant-id>. or spec.config.topics.regex must be anchored ^<tenant-id>\\."
---
apiVersion: self-serve/v1
kind: ResourcePolicy
metadata:
  name: tenant-application-group-no-cross-tenant
spec:
  targetKind: ApplicationGroup
  description: ApplicationGroup metadata.name must equal t-<6 alphanum> and every permission.name must be prefixed by metadata.name + "."
  rules:
    - condition: 'metadata.name.matches("^t-[a-z0-9]{6}$")'
      errorMessage: "ApplicationGroup metadata.name must be a tenant id of the form t-<6 alphanum> (e.g. t-ab12cd)"
    - condition: 'spec.permissions.all(p, p.name != "*" && p.name.startsWith(metadata.name + "."))'
      errorMessage: "Every permission.name must start with the owning tenant id (e.g. t-ab12cd.) — wildcard or cross-tenant prefixes are not allowed"

Each policy must still be linked via Application(Instance).spec.policyRef or KafkaCluster.spec.policiesRef to take effect.

Policies in this bundle

Grouped by category. Click any policy for the rationale, examples, and YAML.

Naming

4

Partitions

2

Replication

2

Retention

1

Schema Enforcement

1

Security & ACLs

4

Operational Hygiene

3

Connectors

1

Enforce this bundle automatically?

Drop these YAMLs into Conduktor Console to get central enforcement, audit history, and pre-commit feedback for every change.

See Conduktor Console →