AclList icon
OutputValues icon
Log icon
If icon
SlackIncomingWebhook icon
Pause icon
Loop icon
AclCreate icon
Fail icon
Schedule icon

Kafka ACL Baseline Restore Guard

Diff live Kafka ACLs against a declared baseline, create the missing bindings with AclCreate behind a Pause, then re-list and fail if a grant is still absent.

Categories
DataInfrastructure

Kafka authorization drifts in the direction nobody watches. A cleanup script written to remove one stale grant removes three. A cluster is rebuilt and the ACL restore stops half way. An audit prunes a wildcard ALLOW that a service was quietly relying on. Nothing breaks for the operator who ran the command. It breaks for a consumer group that starts logging TopicAuthorizationException at 03:00. The only record of what the principal used to hold is in somebody's shell history.

This blueprint makes the required grants a declared input and keeps the cluster matching it. AclList reads every live binding, one pass classifies each baseline entry. AclCreate adds back only what is missing, behind auto_remediate, dry_run and a Pause. A second AclList then re-reads the store, so a run only goes green when every baseline grant is really in force.

This blueprint was created by zkasuran.

Findings

State Meaning Created by remediation
present A live ALLOW binding covers this principal, resource and operation No, nothing to do
missing No live ALLOW covers it, no DENY stands in the way Yes, one AclCreate
blocked A live DENY covers it, so creating the ALLOW would not restore access No, a human decides whether the DENY goes
refused The entry itself is too broad (* literal, operation ALL, patternType MATCH) No, unless allow_broad_entries is on

Coverage follows Kafka's own matching rules rather than string equality, so a baseline entry is present when a broader live grant already satisfies it: operation ALL, a PREFIXED pattern whose prefix the baseline name starts with, the * literal resource name or host *. That is what keeps the flow from piling a second narrow binding on top of a grant that already works.

Safety

  • Read-only with the shipped defaults. AclCreate needs auto_remediate: true, dry_run: false and an approved Pause, in that order.
  • The only write verb in the flow is AclCreate with permissionType: ALLOW. It never deletes a binding, never writes a DENY, never touches a principal that is not in the baseline.
  • refused exists so a baseline cannot smuggle in the grants an ACL audit would take straight back out.
  • The Pause is there for blast radius. A missing grant is a reversible outage rather than a security hole, so the risk being gated is handing a principal access a human has not read, not losing data.

How it works

  1. list_acls (io.kestra.plugin.kafka.AclList, read-only) returns every binding on the cluster, ALLOW and DENY.
  2. plan normalizes the baseline input (patternType defaults to LITERAL, host to *) and classifies each entry as present, missing, blocked or refused in one jq pass.
  3. summary logs the four counts plus every missing grant in full.
  4. route (io.kestra.plugin.core.flow.If):
    • nothing to report: in_force logs that the baseline is satisfied.
    • anything to report: alert posts the counts and the missing grants to Slack.
      • restore_gate runs only when auto_remediate is true, dry_run is false and something is missing. Otherwise report_only logs what it would have created.
      • approve (io.kestra.plugin.core.flow.Pause) waits for a resume carrying approved.
      • apply_gate reads outputs.approve.onResume.approved ?? false, so an unanswered resume is not an approval. When it is false, declined logs that nothing was created.
      • restore (io.kestra.plugin.core.flow.Loop at concurrencyLimit: 1) runs one io.kestra.plugin.kafka.AclCreate per missing binding.
      • relist reads the ACL store again, verify re-runs the same classification against it. verify_gate fails the run through io.kestra.plugin.core.execution.Fail if any baseline grant is still not in force. On a clean result restored logs the new totals.
  5. errors posts to Slack on any failure, for example an unreachable broker or a principal that cannot alter ACLs.

What you get

  • The grants your services depend on, written down as data instead of living in a runbook.
  • A daily read-only check that an ACL cleanup or a half-finished restore has not quietly removed access.
  • A restore that creates only the bindings that are genuinely absent, each one matched against Kafka's real ACL semantics.
  • Proof the restore landed: a second AclList and a failed run if a grant is still missing.
  • A clear split between what this flow can fix (an absent ALLOW) and what it refuses to touch (a DENY in the way, an over-broad entry).

Who it's for

  • Platform teams running Kafka with an authorizer, where several services share one cluster.
  • Anyone who has rebuilt a cluster or restored ACLs from a backup and wants the result checked against the contract.
  • Teams that run ACL pruning and want a counterweight that catches an over-prune the same day.
  • Teams evaluating the Kafka plugin who want AclList plus AclCreate with real gates around them.

Why orchestrate this with Kestra

kafka-acls.sh --add restores a binding, but somebody has to notice it went. The baseline lives next to the flow as a typed input, so a change to it is a reviewed change. Kestra runs the diff on a schedule, keeps every run as evidence of what the cluster held that day, posts the drift to Slack, holds the write behind two inputs plus a human resume, then re-reads the cluster and marks the run red if the grants are still not there. The audit trail is the part a shell loop cannot give you.

Tested end to end

On Kestra 2.0.5 OSS against Apache Kafka 3.9.1 in KRaft mode with StandardAuthorizer, with a local HTTP sink standing in for Slack. Every binding count below was read with the cluster's own kafka-acls.sh --list, before and after each run, not from the Kestra log. The seeded cluster holds the 6 baseline grants plus one unrelated User:metrics READ on heartbeat, which no run ever touched.

Run Cluster state and inputs Result
1 baseline fully seeded, 7 bindings, defaults 6 present, 0 missing. Nothing created, still 7 bindings
2 3 bindings deleted (4 left), defaults 3 missing found, Slack alert sent, nothing created, still 4
3 same 4 bindings, auto_remediate: true, dry_run: true same 3 reported as creatable, still 4 bindings
4 same 4 bindings, auto_remediate: true, dry_run: false, resumed with approved: false declined logged, still 4 bindings
5 same 4 bindings, auto_remediate: true, dry_run: false, resumed with approved: true 3 AclCreate calls, re-list clean, 7 bindings, the PREFIXED grant back as PREFIXED
6 1 grant deleted plus a DENY seeded over a second one (6 bindings), approved 1 missing plus 1 blocked. The missing one created (7 bindings), the run then failed on the re-verify naming the DENY
7 baseline edited to ask for * literal, operation ALL and patternType MATCH all 3 refused as too broad, 0 created
8 unreachable broker list_acls failed, errors alert sent

Runs 1 and 2 are the same flow with the same inputs against two cluster states, which is what shows the check can actually fail: 0 missing on the healthy cluster, 3 missing once the bindings were pulled out from under it.

Prerequisites

  • A Kafka cluster with an authorizer enabled, reachable from the Kestra worker.
  • A principal that can DESCRIBE the cluster to list ACLs, plus ALTER on the cluster to create them. The read path needs only DESCRIBE.
  • Local testing: run apache/kafka:3.9.1 with KAFKA_AUTHORIZER_CLASS_NAME=org.apache.kafka.metadata.authorizer.StandardAuthorizer plus KAFKA_SUPER_USERS=User:ANONYMOUS, then seed the baseline with kafka-acls.sh --add.

Secrets

  • SLACK_WEBHOOK_URL: used by alert and by the errors block. In Kestra OSS, provide it as the base64-encoded environment variable SECRET_SLACK_WEBHOOK_URL.

Inputs

Input Default Purpose
bootstrap_servers localhost:9092 Brokers
baseline 6 example grants for 3 services The ALLOW grants that must exist
allow_broad_entries false Whether a wildcard or ALL baseline entry may be created
auto_remediate false Allow AclCreate
dry_run true Must be false for a create to run

Quick start

  1. Add the SLACK_WEBHOOK_URL secret.
  2. Import the flow, set bootstrap_servers and replace baseline with the grants your services need. kafka-acls.sh --list on a healthy cluster is a good first draft.
  3. Run it with the defaults and read summary. Everything should be present.
  4. Delete one binding you can afford to lose, run it again, then restore it with auto_remediate: true plus dry_run: false and approve the Pause.
  5. Enable the daily trigger, which always runs in safe mode.

How to extend

  • Keep the baseline in Git and pass it in from a io.kestra.plugin.core.http.Request or a repository sync, so the contract has a review history.
  • Add a kv.Set of the last clean classification, then alert on a grant that goes missing twice in a week.
  • Pair it with an ACL pruning flow and run this one straight afterwards, so an over-prune is caught in the same window.
  • Point it at a staging cluster first and diff the two baselines to find grants production has that staging does not.
  • Open a ticket for each blocked entry, since removing a DENY is a decision that needs an owner.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.