New to Kestra?
Use blueprints to kickstart your first workflows.
Audit Supabase/Postgres tables for missing row level security via RPC, alert Slack with the exact gaps, read-only.
Supabase's own security checklist names it directly: every table exposed through
the REST API should have row level security enabled, or any API key that can read
it can read every row. A table created mid-sprint without RLS, meant to be locked
down "later," is exactly the kind of gap that survives in production because
nothing ever checks for it again. This blueprint calls a small Postgres function,
created once ahead of time, that lists every public-schema table with
relrowsecurity still false, and alerts Slack by name the moment that list is
not empty.
periodic_rls_check (io.kestra.plugin.core.trigger.Schedule) runs every 6 hours. Shipped disabled so you can validate a manual run first.check_tables_without_rls (io.kestra.plugin.supabase.Query) calls POST /rest/v1/rpc/{rpc_function_name}, invoking the Postgres function defined in Prerequisites. Its result lands in rows (one map per table) and size (the count).evaluate_rls_compliance (io.kestra.plugin.core.flow.If) branches on size == 0.then (HEALTHY): log_rls_healthy records a clean check.else (AT_RISK): alert_rls_gap names every affected schema.table in the Slack message.log_audit_complete always runs last, printing the headline count regardless of which branch fired.errors block alerts Slack separately if the flow itself fails outright - an unreachable project, a bad API key, or rpc_function_name not existing yet must not read as "no gap".pg_class.relrowsecurity flag, the authoritative source Supabase's own dashboard uses - not a guess based on table naming or a cached inventory.schema.table names in the Slack alert, so remediation starts with a list, not a bare count.SELECT - it cannot enable RLS, write a policy, or touch any table itself.Query calling a custom RPC function for a metadata/governance read, distinct from this repo's existing supabase-analytics-duckdb-slack-digest.yaml, which queries business data, not schema security state.The underlying SQL function run by hand in the Supabase SQL editor answers one moment in time; it does not decide a cadence, classify the result as a branch, or notify anyone. Kestra supplies the schedule, the HEALTHY/AT_RISK classification as a first-class branch, and an execution history that shows exactly when a table went unprotected and for how long before anyone noticed.
CREATE OR REPLACE FUNCTION get_tables_without_rls()
RETURNS TABLE(table_name text, schema_name text)
LANGUAGE sql
SECURITY DEFINER
AS $$
SELECT c.relname::text, n.nspname::text
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
AND n.nspname = 'public'
AND c.relrowsecurity = false;
$$;
relkind = 'r' and relrowsecurity are both documented pg_class columns (verified from the official PostgreSQL catalog documentation); SECURITY DEFINER is required because the API role this flow authenticates as typically cannot read pg_class directly.SECURITY DEFINER catalog function by its own RLS-adjacent restrictions, so this audit function needs the elevated key.Local testing: Supabase's local/self-hosted stack is itself a multi-container system (Postgres, PostgREST, GoTrue, Realtime, Kong, and Studio, among others) - there is no single-container equivalent. Use Supabase's own official docker-compose.yml for self-hosting (documented at the link below) or the supabase start CLI command, which runs the same stack locally; either way, confirm the actual Kong gateway port before setting supabase_url - the official self-hosted compose file defaults it to 8000, which does not collide with Kestra's own UI on 8080.
SUPABASE_SERVICE_ROLE_KEY: the Supabase service-role API key, used as apiKey on check_tables_without_rls. Never use the anon/public key here - it carries broad database privileges and must never be exposed client-side.SLACK_WEBHOOK_URL: Slack incoming webhook used by alert_rls_gap and the errors block.SECRET_, base64-encoded, and read back in flows with {{ secret('NAME') }} - for example SECRET_SUPABASE_SERVICE_ROLE_KEY=$(echo -n '<your-local-key>' | base64). This keeps the key out of the flow YAML but, per Kestra's own documentation, offers no encryption at rest or access control beyond the host environment; use the Enterprise secrets backend for stronger guarantees.supabase_url (STRING, default http://localhost:8000): Supabase project URL (self-hosted Kong gateway or Cloud project URL).rpc_function_name (STRING, default get_tables_without_rls): name of the pre-created audit function.outputs.check_tables_without_rls.rows: one map per table without RLS (table_name, schema_name), every run.outputs.check_tables_without_rls.size: count of tables without RLS, every run.get_tables_without_rls function from Prerequisites in your Supabase project's SQL editor.public schema with RLS left disabled on purpose.SUPABASE_SERVICE_ROLE_KEY and SLACK_WEBHOOK_URL secrets.alert_rls_gap fires, naming your test table.ALTER TABLE your_test_table ENABLE ROW LEVEL SECURITY; (with an appropriate policy - see Pitfalls) and re-run to confirm log_rls_healthy fires instead.periodic_rls_check once you trust the check.pg_policies for tables that have RLS enabled but zero policies defined - relrowsecurity = true with no policies still blocks all access by default, a different but related gap this flow does not currently surface.public, if your project exposes more than one through the API.supabase_url/project pairs with io.kestra.plugin.core.flow.Loop to run the same audit across multiple Supabase projects in one flow.Query only ever sends POST /rest/v1/rpc/{functionName}. There is no task in this plugin for arbitrary SELECT statements, which is exactly why the audit logic lives in a Postgres function created ahead of time, not inline in this flow's YAML.ALTER TABLE ... ENABLE ROW LEVEL SECURITY with no matching policy blocks every row from every client immediately - turning an over-exposed table into a completely broken one. This is exactly why alert_rls_gap only reports; writing a correct policy needs to know who should actually have access, which this flow cannot infer.SECURITY DEFINER and a service-role key. pg_class is a system catalog most API roles cannot read directly; without both, check_tables_without_rls fails outright (caught by the errors block) rather than returning an empty, falsely-healthy result.public schema, hardcoded in the SQL function shown in Prerequisites, since that is what Supabase's REST API exposes by default. A project exposing additional schemas needs the function (or a second one) extended to cover them - see How to extend.relrowsecurity = true does not mean the table is actually protected. A table can have RLS enabled with zero policies, which blocks all access rather than granting the intended access - this flow only detects the disabled case, not that related misconfiguration (see How to extend).Query task's properties (url, apiKey, functionName, parameters) and outputs (rows, size, code) are taken verbatim from the plugin's source on main, and the pg_class.relrowsecurity/relkind/relnamespace columns are taken verbatim from PostgreSQL's own catalog documentation - but no Docker container, Supabase stack, or Kestra engine was run to execute this flow end to end.