Schedule icon
Query icon
If icon
Log icon
SlackIncomingWebhook icon

Supabase RLS Policy Compliance Gate

Audit Supabase/Postgres tables for missing row level security via RPC, alert Slack with the exact gaps, read-only.

Categories
DataInfrastructure

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.

How it works

  1. periodic_rls_check (io.kestra.plugin.core.trigger.Schedule) runs every 6 hours. Shipped disabled so you can validate a manual run first.
  2. 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).
  3. 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.
  4. log_audit_complete always runs last, printing the headline count regardless of which branch fired.
  5. The 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".

What you get

  • A direct read of Postgres's own pg_class.relrowsecurity flag, the authoritative source Supabase's own dashboard uses - not a guess based on table naming or a cached inventory.
  • The exact schema.table names in the Slack alert, so remediation starts with a list, not a bare count.
  • A read-only design: this flow calls one RPC function that only runs a SELECT - it cannot enable RLS, write a policy, or touch any table itself.
  • A final status log every run, gap or not, so the execution history doubles as an RLS-compliance trend for the whole public schema.

Who it's for

  • Teams running Supabase (self-hosted or Cloud) who want a standing check that every public-facing table actually has RLS enabled, not just the ones someone remembered to lock down.
  • Security-conscious teams doing periodic access reviews who want the exact gap list pushed to Slack instead of running the check by hand in the SQL editor.
  • Anyone evaluating the Supabase plugin who wants a worked example of 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.

Why orchestrate this with Kestra

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.

Prerequisites

  • A reachable Supabase project (self-hosted or Cloud) with the following function created once, in the Supabase SQL editor or any Postgres client with sufficient privileges, before this flow is ever run:
    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.
  • A service-role API key - the anon/public key is usually blocked from calling a SECURITY DEFINER catalog function by its own RLS-adjacent restrictions, so this audit function needs the elevated key.
  • A Slack incoming webhook for alerts.

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.

Secrets

  • 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.
  • In Kestra OSS (no Enterprise secrets backend), secrets are supplied as environment variables prefixed 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.

Inputs

  • 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

  • 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.

Quick start

  1. Create the get_tables_without_rls function from Prerequisites in your Supabase project's SQL editor.
  2. Create a test table in the public schema with RLS left disabled on purpose.
  3. Add the SUPABASE_SERVICE_ROLE_KEY and SLACK_WEBHOOK_URL secrets.
  4. Run the flow manually with the defaults and confirm alert_rls_gap fires, naming your test table.
  5. Run 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.
  6. Enable periodic_rls_check once you trust the check.

How to extend

  • Extend the SQL function to also 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.
  • Add a second RPC function and task auditing a different schema than public, if your project exposes more than one through the API.
  • Loop over several supabase_url/project pairs with io.kestra.plugin.core.flow.Loop to run the same audit across multiple Supabase projects in one flow.
  • Route repeated AT_RISK runs (the same table still unprotected after N consecutive checks) to a higher-severity channel, since a gap that survives several audit cycles is a stronger signal than a brand-new one.

Pitfalls

  • This plugin calls RPC functions, not raw SQL. Verified from the plugin's own source: 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.
  • Enabling RLS automatically would be more dangerous than the gap it fixes. 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.
  • The audit function needs 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.
  • This only checks the 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).
  • This blueprint is UNTESTED against a live Supabase project. The 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.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.