Schedule icon
Get icon
Commands icon
Docker icon
Script icon
If icon
SlackIncomingWebhook icon
Set icon
Pause icon
Loop icon

ServiceAccountWatch - Kubernetes unused ServiceAccount auditor

A Kestra blueprint that lists Kubernetes ServiceAccounts with the plugin task, cross-references them against the pods using them, reports the unused ones on Slack, and optionally deletes them after a human approval gate.

Categories
Infrastructure

How it works

  1. get_accounts (io.kestra.plugin.kubernetes.kubectl.Get) lists the namespace's ServiceAccounts with the Kubernetes plugin task.
  2. list_pods (io.kestra.plugin.scripts.shell.Commands) dumps the namespace's pods as JSON with kubectl. Pods stay on kubectl because the plugin Get output carries metadata and status only, and the cross-reference needs spec.serviceAccountName, which is not in there.
  3. find_unused (io.kestra.plugin.scripts.python.Script) builds the set of accounts pods actually run as (a pod with no explicit serviceAccountName runs as default) and flags every ServiceAccount outside that set. It compares against the KV state so only newly found unused accounts are reported.
  4. maybe_alert (io.kestra.plugin.core.flow.If) posts the Slack report only when there is someone new to name.
  5. save_state (io.kestra.plugin.core.kv.Set) writes back the current unused list. Deleted accounts drop out on their own, so a reappearing unused account reports again, and steady-state reruns stay quiet.
  6. maybe_cleanup (io.kestra.plugin.core.flow.If) runs only when delete_after_approval is on and there is something to clean. approve_cleanup (io.kestra.plugin.core.flow.Pause) halts the run until a cluster admin resumes it from the UI with approval; if nobody responds within 24 hours the run cancels and nothing is deleted. check_approval then deletes each reported account (kubectl delete serviceaccount, one per account) and posts a Slack confirmation naming what was deleted.

What you get

  • A daily audit of ServiceAccounts nothing runs as: stale accounts left behind by removed workloads and renamed deploys.
  • Slack reports naming each unused account, once, until the list changes.
  • A cleanup path that is human-gated, not automatic: with delete_after_approval on, deletion happens only after an admin approves the exact list at the Pause gate.
  • Quiet runs when every account in the namespace has a pod behind it, and no deletion ever when the input is off (the default).

Who it's for

Anyone whose namespaces have accumulated ServiceAccounts over months of deploys and wants the cleanup list, plus an approval-gated way to act on it without handing a bot standing delete rights over identity objects. Pairs with RBACWatch: this one covers unused accounts, that one covers over-powerful bindings.

Why orchestrate this with Kestra

kubectl get sa never told you which accounts are dead, and deleting them by hand is the kind of cleanup that never happens. As a flow it audits on a schedule, keeps every run's findings where you can review them, only bothers you when a new unused account appears, and puts the delete decision in front of a human with the list attached. Kestra holds the cluster credentials as secrets out of the YAML.

Prerequisites

  • A Kubernetes cluster with ServiceAccounts and pods you can list.
  • A kubeconfig client certificate and key with permission to list pods and delete ServiceAccounts in the target namespace (for the kubectl steps).
  • A ServiceAccount token with permission to list ServiceAccounts in the target namespace (for the plugin Get task). On minikube: create a ServiceAccount, bind it the view ClusterRole, and mint a token with kubectl create token.
  • Kestra with the Docker task runner available (used for the kubectl and Python steps).
  • A Slack Incoming Webhook URL.

Secrets

Secret What it is
CLIENT_CRT base64-encoded client certificate from your kubeconfig
CLIENT_KEY base64-encoded client key from your kubeconfig
MASTERURL base64-encoded Kubernetes API server URL, reachable from inside the Kestra container
K8S_TOKEN base64-encoded ServiceAccount token for the plugin Get task
SLACK_WEBHOOK base64-encoded Slack Incoming Webhook URL

In Kestra OSS, secrets come from environment variables prefixed with SECRET_ and must be base64-encoded. If the API URL uses 127.0.0.1 or localhost, replace it with host.docker.internal so the Kestra container can reach it. trustCerts is on for the plugin task because minikube serves a self-signed certificate; on a cluster with a proper CA, drop it.

Quick start

  1. Start Kestra with the secrets above as SECRET_ environment variables. If minikube restarted since your last run, regenerate SECRET_MASTERURL first: the API port changes on every minikube start. Mint the token: kubectl create serviceaccount kestra-reader -n default, kubectl create clusterrolebinding kestra-reader --clusterrole=view --serviceaccount=default:kestra-reader, then kubectl create token kestra-reader -n default and base64-encode it as SECRET_K8S_TOKEN.
  2. Import this flow and create two ServiceAccounts: kubectl create sa orphan-sa (nothing uses it) and kubectl create sa used-sa, plus a pod that runs as used-sa. Run the flow with defaults: the Slack report names only orphan-sa, and nothing is deleted.
  3. Run again: quiet, already reported.
  4. Run with delete_after_approval: true: the report posts, then the run pauses. Resume it from the execution's Gantt tab with approval checked: orphan-sa is deleted and a Slack confirmation names it. kubectl delete sa orphan-sa and run again: quiet, it dropped out of state.

How to extend

  • Cross-reference RoleBindings too, and split the report into accounts with bindings (risky) and accounts with none (safe cleanup).
  • Audit every namespace by wrapping the flow in a Loop over a namespace list.
  • Age the findings: only offer deletion for accounts unused for more than N days using creationTimestamp.
  • Shorten or lengthen the approval window by changing pauseDuration; behavior: CANCEL keeps the no-response case safe by cancelling instead of deleting.

Inputs

Input Type Default What it does
namespace STRING default the namespace whose ServiceAccounts to audit
delete_after_approval BOOL false when true, pause for admin approval and then delete the reported unused ServiceAccounts. when false, report only
slack_webhook STRING (required) Incoming webhook URL of the Slack channel for the report

Links

Pitfalls

  • Deletion is off by default. With delete_after_approval off, this flow is strictly read-only and never touches an account, whatever the report says.
  • With approval on, the Pause gate is the only thing between the report and deletion. The approver must check the list in the Slack report before resuming; a resume without the approval box checked deletes nothing.
  • If nobody responds within 24 hours, behavior: CANCEL cancels the run. Nothing is deleted on timeout, by design.
  • An account that looks stale might belong to a CI job that runs monthly. That is exactly why deletion is approval-gated instead of automatic.
  • Every pod counts as a reference, including completed Job pods. Conservative on purpose: an account a finished job used is provisioned for its next run.
  • The default ServiceAccount is flagged when no pod runs as it. That is correct, and on a fresh namespace it may be the only finding.
  • The plugin Get task returns metadata and status only, which is why pods are still listed with kubectl: the cross-reference needs spec.serviceAccountName, and that field is not in the Get output.
  • After an approved deletion, the KV state still lists the deleted accounts until the next run, when they are gone from the listing and drop out on their own.
  • Minikube's API server port changes on every minikube start. Regenerate SECRET_MASTERURL and restart the Kestra container, or the steps fail to connect.
See How

New to Kestra?

Use blueprints to kickstart your first workflows.