Schedule icon
If icon
Fail icon
Consume icon
IonToJson icon
OutputValues icon
Log icon
Pause icon
Delete icon
Sleep icon
SlackIncomingWebhook icon

Purge forbidden keys from a NATS JetStream KV bucket against an allowlist

Audit a NATS JetStream KV bucket against an allowlist, delete forbidden keys behind a Pause, then re-read and fail if any survived.

Categories
DataInfrastructure

Findings

Measured on NATS Server 2.10 with plugin-nats 1.5.1 while this blueprint was built.

  • plugin-nats has no KV list task. kv.Get needs the key names up front, which is useless when the whole point is finding keys you did not expect. A KV bucket is a JetStream stream named KV_<bucket> whose subjects are $KV.<bucket>.<key>, so nats.core.Consume with deliverPolicy: LastPerSubject over $KV.<bucket>.> returns one record per key. That is the same mechanism nats kv ls uses.
  • A deleted key keeps its subject. Its newest record is a tombstone carrying the KV-Operation header with an empty body. Without filtering on that header every key ever deleted reads as still present, so the re-verify would fail every successful purge. A clean-bucket run here scanned 6 records for 3 live keys.
  • LastPerSubject only promises one record per subject for the backlog. A key written while the scan window is open arrives again as a fresh record. One run with a writer publishing every two seconds scanned 17 records for 6 distinct keys, so the key list has to be deduplicated or a busy bucket reports the same key many times.
  • deletedCount is the number of keys submitted, not the number removed. A KV delete of a key that is already gone still succeeds. The count carries no information about whether the purge landed, which is why the re-read decides.

Safety

Read-only unless told otherwise. Three refusals stand before anything is deleted.

  • Default inputs are dry_run: true with auto_remediate: false, so a first run reports only. A delete needs auto_remediate: "true", dry_run: "false" plus an approved Pause.
  • An empty allowed_keys list fails the run before the bucket is read. Every live key would be forbidden, so a blank input would propose wiping the bucket. A blank ARRAY input is far more likely to be a misconfiguration than an instruction.
  • A forbidden count above max_purge_batch fails the run. A sudden jump usually means the allowlist is wrong rather than that the bucket filled up with junk.
  • kv.Delete is only ever handed the computed forbidden list. The allowlist never reaches that task, so an allowlisted key cannot be named there by accident. The re-verify fails the run if the allowlisted count drops.

How it works

  1. refuse_empty_allowlist (If) fails the run on an empty allowed_keys before any NATS connection is made.
  2. list_live_keys (nats.core.Consume) reads $KV.<bucket_name>.> with deliverPolicy: LastPerSubject, bounded by scan_max_records plus scan_max_duration.
  3. decode_entries (serdes.json.IonToJson) turns the Ion records into one JSON array.
  4. classify_keys (core.output.OutputValues) runs one jq pass: drop tombstones, recover the key name from the subject, deduplicate, then split the result into forbidden, kept plus allowlisted-but-absent.
  5. report_audit logs the counts with the forbidden key names on every run.
  6. refuse_oversized_purge (If) fails the run when the forbidden count exceeds max_purge_batch in write mode.
  7. purge_forbidden_keys (If) is the write gate. It needs a forbidden key, auto_remediate: "true" plus dry_run: "false".
  8. confirm_purge (Pause) holds for a human who answers approved. The gate below reads onResume.approved ?? false, so a resume with no answer is not an approval.
  9. delete_forbidden_keys (nats.kv.Delete) removes every forbidden key in one call.
  10. settle_after_purge (Sleep) lets the tombstones land, then reverify_live_keys reads the bucket again and reverify_classify classifies it with the same jq program.
  11. fail_if_purge_did_not_land (If) fails the run when a forbidden key is still live or when fewer allowlisted keys are present than before.
  12. The errors block posts to Slack on any failure.

What you get

  • A key-level inventory of a KV bucket without naming the keys in advance.
  • Named deletion candidates on a read-only run, so the allowlist can be checked before any key is touched.
  • A delete that is proved by a second read rather than by a task going green.
  • A run that fails loudly when a purge does not stick, which is the signal that a decommissioned writer is still alive.

Who it's for

Platform teams who keep feature flags, leases or service config in NATS KV. A bucket with no TTL accumulates keys from every service that ever wrote to it, including the ones that were turned off, so readers cannot tell which key is authoritative. This is the cleanup pass that is safe to schedule.

Why orchestrate this with Kestra

The two-input gate plus the Pause give the bucket owner a report to read before any key is deleted, with the approval recorded on the execution. The re-read after the delete is a task that can fail the run, which a shell script in a cron job will not do for you. The Slack errors block turns a refused or failed purge into an alert instead of a silent exit code.

Tested end to end

Run against NATS Server 2.10 in JetStream mode on Kestra 2.0.5 with plugin-nats 1.5.1. The bucket held 3 allowlisted keys plus 3 forbidden ones (an orphaned flag, an abandoned lease, a key from a decommissioned service), confirmed with nats kv ls before and after each run.

  • Clean bucket, same inputs: 6 records scanned, 3 live keys, 0 forbidden.
  • Degraded bucket, same inputs: 6 live keys, 3 forbidden, named in the log.
  • dry_run: true: the 3 forbidden keys reported, nats kv ls still 6 keys.
  • Approved purge: nats kv ls 6 keys before, 3 after, exactly the allowlisted ones left. The re-read reported 0 surviving with the allowlisted count unchanged at 3.
  • Declined Pause: nothing deleted, nats kv ls still 6 keys.
  • Empty allowlist: run failed at the refusal, bucket never read.
  • Forbidden count 3 against max_purge_batch: 1: run failed at the ceiling refusal.
  • Re-verify failure: with a writer re-creating one forbidden key every two seconds, the delete submitted 3, the re-read found 1 still live, the run failed. nats kv ls showed 4 keys afterwards, the 3 allowlisted plus the one being rewritten.

Prerequisites

  • A reachable NATS Server with JetStream enabled.
  • An existing KV bucket. This flow never creates one.
  • plugin-nats on your Kestra instance (bundled in the full Kestra image).

Secrets

  • SLACK_WEBHOOK_URL: incoming webhook for the failure alert.
  • NATS credentials if your server requires them. plugin-nats accepts username, password, token or creds on every NATS task. Add them as secret() properties on list_live_keys, delete_forbidden_keys plus reverify_live_keys. The flow is shipped without them because the test server runs unauthenticated.

Inputs

  • nats_url: server URL, default nats://localhost:4222.
  • bucket_name: the KV bucket to audit. It has to exist.
  • allowed_keys (ARRAY): the keys the bucket is allowed to hold. Empty is refused.
  • max_purge_batch (INT): ceiling on one purge, default 25.
  • scan_max_records (INT): ceiling on the bucket scan, default 500.
  • scan_max_duration (DURATION): scan timeout, default PT20S.
  • settle_after_purge (DURATION): wait before the re-read, default PT3S.
  • auto_remediate (SELECT): "false" by default.
  • dry_run (SELECT): "true" by default.

Quick start

  1. Point nats_url plus bucket_name at your bucket and list its legitimate keys in allowed_keys.
  2. Run with the defaults. Read report_audit and check every name under forbidden_keys really is dead.
  3. Fix allowed_keys if anything live was listed, then run again until the forbidden list is only keys you want gone.
  4. Run with auto_remediate: "true" plus dry_run: "false", answer approved: true at the Pause, then read reverify_report.
  5. Enable the Schedule trigger once a manual run looks right. It pins safe mode, so it reports without deleting.

How to extend

  • Read the allowlist with kv.Get from a control bucket instead of a flow input, so it is maintained where the services are.
  • Add an age filter: the Consume records carry a timestamp, so a key can be spared until it has been untouched for a given number of days.
  • Keep a copy before deleting by writing each forbidden key's value to a Kestra KV store entry in the same iteration, which gives you a restore path.
  • Point the errors block at PagerDuty or Opsgenie if a stuck purge should page.

Links

This blueprint was created by zkasuran.

See How

New to Kestra?

Use blueprints to kickstart your first workflows.