Schedule icon
List icon
Commands icon
Docker icon
Log icon
If icon
Pause icon
Upload icon
SlackIncomingWebhook icon

Enforce default SSE-S3 encryption on a MinIO bucket

Detect a MinIO bucket with no default encryption, apply SSE-S3 behind an approval, then verify a new object comes back encrypted.

Categories
DataInfrastructure

Findings

Four verdicts come out of one run.

verdict what it means does the flow write
UNENCRYPTED get-bucket-encryption answered ServerSideEncryptionConfigurationNotFoundError, so every new object lands as plaintext yes, behind the gate plus the pause
ENCRYPTED_SSE_S3 an AES256 default is already there no
ENCRYPTED_KMS an aws:kms default is there, stronger than what this flow would apply no, the key reference is reported then left alone
PROTECTED the bucket is named in protected_buckets no, it is still inventoried then reported

Safety

Writing a bucket encryption configuration is a whole document PUT. It replaces whatever was there, it does not merge. So the flow writes only when there is nothing to replace. A bucket that already carries any default rule is reported then skipped, which is why an existing KMS setup cannot be downgraded to SSE-S3 by this flow.

The remediation writes one object of its own, under .kestra-encryption-probe/<execution id>, then deletes it in the verification step. That object exists because a default is invisible until something new gets written: reading it back is the proof the setting took effect rather than just being stored. If the delete fails the log says so by name.

How it works

  1. inventory_objects (io.kestra.plugin.minio.List) lists every current object with its size plus lastModified. These are the objects a default will not protect, so this listing is the number the approval is made against. Its oldest key becomes the plaintext baseline read back at the end.
  2. assess_encryption (io.kestra.plugin.scripts.shell.Commands on the AWS CLI image) runs get-bucket-encryption, treats the specific not-found error code as the finding, treats any other failure as an outage that fails the task, classifies the bucket then writes the exact configuration document a write would send.
  3. log_assessment (io.kestra.plugin.core.log.Log) puts the verdict plus the counts in the execution log whatever happens next.
  4. remediation_gate (io.kestra.plugin.core.flow.If) passes only when the verdict is UNENCRYPTED, auto_remediate is true plus dry_run is false.
    • approve_encryption (io.kestra.plugin.core.flow.Pause) waits for an operator. Resuming with no answer counts as a refusal.
    • apply_encryption runs put-bucket-encryption with an AES256 default. Its exit status is logged then ignored.
    • write_probe_object (io.kestra.plugin.minio.Upload) writes the probe object.
    • reverify_encryption reads the configuration back, heads the probe object, heads the oldest pre-existing object, deletes the probe then exits non-zero unless the bucket carries an SSE-S3 default AND the fresh write came back as AES256.
  5. errors posts to Slack when any task fails, including a read back that found no default.

What you get

  • A one-line verdict per bucket with the algorithm, the rule count plus the exact error code the bucket answered with.
  • The count plus the byte total of objects that stay plaintext after the fix, printed before the approval rather than discovered later.
  • A side by side read of one new object against one old object, which is the only direct evidence that a default is live.
  • A write that cannot happen by accident: two input switches, a pause, a verdict check plus an exclusion list all have to agree.
  • A run that FAILS when the configuration is not there afterwards, so a silently refused write cannot pass as a fix.

Who it's for

Platform teams running MinIO or S3 compatible storage who have to answer "is data at rest encrypted" with something better than a guess. It fits a compliance sweep, a post-incident hardening pass or a new bucket checklist, where the finding is cheap to produce plus the fix is one call that nobody has made yet.

Why orchestrate this with Kestra

A one-liner can turn encryption on. It cannot pause for the person who owns the data, record which objects stay plaintext, prove the setting applies to a new write, then fail loudly when the storage layer quietly ignored the call. Kestra holds the inputs that force safe mode, the pause that puts a human on the write, the typed MinIO tasks for the inventory plus the probe, the read back that decides the outcome plus the error path that reports it. The schedule turns the same file into a weekly read-only audit with no edit.

Tested end to end

Run against MinIO DEVELOPMENT.2025-05-24T17-08-30Z on Kestra 2.0.5 with plugin-minio 1.4.8. Buckets were seeded with 4 objects each under logs/, staging/ plus the root, then every path was confirmed with mc encrypt info rather than with the Kestra log.

  • Bare bucket, safe mode: verdict UNENCRYPTED, error code ServerSideEncryptionConfigurationNotFoundError, 4 objects at 335 bytes reported as plaintext, nothing written.
  • Already encrypted bucket, identical inputs: verdict ENCRYPTED_SSE_S3, needs_rule no, zero action. Same flow, same switches, opposite answer, which is what makes the detector worth anything.
  • Bare bucket with auto_remediate=true dry_run=false plus an approved pause: the default landed (mc encrypt info went from the not-found error to Auto encryption 'sse-s3' is enabled), the probe object came back ServerSideEncryption AES256, the oldest pre-existing object logs/day1.json came back with no encryption header at all, the probe was deleted.
  • Declined pause on a second bare bucket: still no configuration afterwards per mc.
  • Protected bucket with both switches on: verdict PROTECTED, no write, configuration still unset.
  • Refused write: against a MinIO with no key source the put failed with NotImplemented at exit 254 while the Kestra task still reported SUCCESS. The read back caught it, the run ended FAILED and the Slack error path posted. That is the whole reason the read back is the gate.

Prerequisites

Secrets

secret why
MINIO_ACCESS_KEY_ID access key for the inventory, the read plus the write
MINIO_SECRET_KEY_ID secret key for that access key
SLACK_WEBHOOK_URL incoming webhook for the failure alert

The key needs s3:ListBucket plus s3:GetEncryptionConfiguration for the audit, then s3:PutEncryptionConfiguration, s3:PutObject plus s3:DeleteObject for the remediation and its probe. Give the scheduled audit a read-only key if you want the write to be impossible.

MinIO needs a key source configured before it will accept SSE-S3, either KES or the built-in MINIO_KMS_SECRET_KEY. Without one, put-bucket-encryption is rejected and the read back fails the run, which is the correct outcome. Real S3 needs nothing extra. The flow itself never names or manages a key.

Inputs

input default why
dry_run true report the configuration, write nothing
auto_remediate false second switch the write needs
bucket none the bucket to assess, one per run
protected_buckets empty comma separated names this flow may never write to
max_keys 1000 ceiling on the inventory listing
minio_endpoint http://minio.internal:9000 S3 API endpoint
minio_access_key secret access key
minio_secret_key secret secret key
awscli_image public.ecr.aws/aws-cli/aws-cli:latest AWS CLI plus python3 in one image
docker_network bridge network the client container joins

Quick start

  1. Add the three secrets.
  2. Run with bucket set plus the defaults. You get the verdict, the error code the bucket answered with, the plaintext object count plus the configuration document, with nothing written.
  3. Read the plaintext count. That is the part this fix does not cover.
  4. To fix it, run again with auto_remediate=true plus dry_run=false, then approve the pause. The verification prints the new object encrypted next to the old one unencrypted.
  5. Enable the schedule for a weekly read-only audit. Its inputs force safe mode.

How to extend

  • Point it at every bucket by wrapping the flow in a parent that loops over aws s3api list-buckets with concurrencyLimit: 1, so one approval still covers one bucket.
  • Swap AES256 for aws:kms with a KMSMasterKeyID where you run KES plus want per-key control. The verdict logic already treats a KMS default as stronger.
  • Close the other half: after the default lands, copy each pre-existing object over itself so it gets rewritten through the new default. That rewrites real data, so gate it on its own pause plus an ETag check per object.
  • Send the verdict to your compliance store instead of Slack by adding a task after log_assessment.

Links

This blueprint was created by zkasuran.

See How

New to Kestra?

Use blueprints to kickstart your first workflows.