env | grep -i supabase | sed 's/=.*/=***/'
SUPABASE_DB_PASSWORD=*** SUPABASE_DB_URL=*** SUPABASE_URL=*** SUPABASE_SERVICE_ROLE_KEY=*** SUPABASE_PROJECT_REF=*** SUPABASE_ANON_KEY=***
echo "SUPABASE_URL=$SUPABASE_URL"; echo "SUPABASE_PROJECT_REF=$SUPABASE_PROJECT_REF"; echo "SUPABASE_DB_URL=$SUPABASE_DB_URL"
SUPABASE_URL=https://<ref>.supabase.co SUPABASE_PROJECT_REF=<ref> SUPABASE_DB_URL=<redacted:SUPABASE_DB_URL>
which psql jq curl; psql --version
/usr/bin/psql /usr/bin/jq /usr/bin/curl psql (PostgreSQL) 18.4 (Debian 18.4-1.pgdg12+1)
export PGPASSWORD="$SUPABASE_DB_PASSWORD" psql "$SUPABASE_DB_URL" -c "\dt public.*"
List of tables Schema | Name | Type | Owner --------+-------------+-------+---------- public | org_members | table | postgres public | orgs | table | postgres public | tickets | table | postgres (3 rows)
export PGPASSWORD="$SUPABASE_DB_PASSWORD" psql "$SUPABASE_DB_URL" -c "\d public.orgs" psql "$SUPABASE_DB_URL" -c "\d public.org_members" psql "$SUPABASE_DB_URL" -c "\d public.tickets"
Table "public.orgs"
Column | Type | Collation | Nullable | Default
--------+------+-----------+----------+-------------------
id | uuid | | not null | gen_random_uuid()
name | text | | not null |
Indexes:
"orgs_pkey" PRIMARY KEY, btree (id)
"orgs_name_key" UNIQUE CONSTRAINT, btree (name)
Referenced by:
TABLE "org_members" CONSTRAINT "org_members_org_id_fkey" FOREIGN KEY (org_id) REFERENCES orgs(id) ON DELETE CASCADE
TABLE "tickets" CONSTRAINT "tickets_org_id_fkey" FOREIGN KEY (org_id) REFERENCES orgs(id) ON DELETE CASCADE
Table "public.org_members"
Column | Type | Collation | Nullable | Default
---------+------+-----------+----------+---------
org_id | uuid | | not null |
user_id | uuid | | not null |
role | text | | not null |
Indexes:
"org_members_pkey" PRIMARY KEY, btree (org_id, user_id)
Check constraints:
"org_members_role_check" CHECK (role = ANY (ARRAY['admin'::text, 'member'::text]))
Foreign-key constraints:
"org_members_org_id_fkey" FOREIGN KEY (org_id) REFERENCES orgs(id) ON DELETE CASCADE
"org_members_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE
Table "public.tickets"
Column | Type | Collation | Nullable | Default
--------+--------+-----------+----------+----------------------------------
id | bigint | | not null | generated by default as identity
org_id | uuid | | not null |
title | text | | not null |
status | text | | not null | 'open'::text
Indexes:
"tickets_pkey" PRIMARY KEY, btree (id)
Foreign-key constraints:
"tickets_org_id_fkey" FOREIGN KEY (org_id) REFERENCES orgs(id) ON DELETE CASCADE
export PGPASSWORD="$SUPABASE_DB_PASSWORD" psql "$SUPABASE_DB_URL" -c "SELECT id, name FROM public.orgs ORDER BY name;" psql "$SUPABASE_DB_URL" -c "SELECT o.name, m.role, u.email FROM public.org_members m JOIN public.orgs o ON o.id=m.org_id JOIN auth.users u ON u.id=m.user_id ORDER BY o.name, u.email;" psql "$SUPABASE_DB_URL" -c "SELECT o.name, t.id, t.title, t.status FROM public.tickets t JOIN public.orgs o ON o.id=t.org_id ORDER BY o.name, t.id;"
id | name --------------------------------------+-------- f8b742f0-0eed-45e5-b117-78e93370f5f2 | Acme f8fa859d-4fb6-45f1-ac88-221e3832da2b | Globex (2 rows) name | role | email --------+--------+----------------------- Acme | admin | uma-admin@cari.dev Acme | member | vic-member@cari.dev Globex | member | wen-outsider@cari.dev (3 rows) name | id | title | status --------+----+----------------+-------- Acme | 1 | Fix login | open Acme | 2 | Update billing | open Acme | 3 | Rotate keys | open Globex | 4 | Migrate DNS | open Globex | 5 | Renew cert | open (5 rows)
export PGPASSWORD="$SUPABASE_DB_PASSWORD"
psql "$SUPABASE_DB_URL" -c "SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname IN ('orgs','org_members','tickets') AND relnamespace='public'::regnamespace;"
psql "$SUPABASE_DB_URL" -c "SELECT schemaname, tablename, policyname, permissive, roles, cmd, qual, with_check FROM pg_policies WHERE schemaname='public' ORDER BY tablename, policyname;"
psql "$SUPABASE_DB_URL" -c "SELECT grantee, privilege_type FROM information_schema.role_table_grants WHERE table_schema='public' AND table_name IN ('orgs','org_members','tickets') ORDER BY table_name, grantee, privilege_type;" relname | relrowsecurity | relforcerowsecurity
-------------+----------------+---------------------
org_members | f | f
orgs | f | f
tickets | f | f
(3 rows)
schemaname | tablename | policyname | permissive | roles | cmd | qual | with_check
------------+-----------+------------+------------+-------+-----+------+------------
(0 rows)
grantee | privilege_type
---------------+----------------
anon | DELETE
anon | INSERT
anon | REFERENCES
anon | SELECT
anon | TRIGGER
anon | TRUNCATE
anon | UPDATE
authenticated | DELETE
authenticated | INSERT
authenticated | REFERENCES
authenticated | SELECT
authenticated | TRIGGER
authenticated | TRUNCATE
authenticated | UPDATE
postgres | DELETE
postgres | INSERT
postgres | REFERENCES
postgres | SELECT
postgres | TRIGGER
postgres | TRUNCATE
postgres | UPDATE
service_role | DELETE
service_role | INSERT
service_role | REFERENCES
service_role | SELECT
service_role | TRIGGER
service_role | TRUNCATE
service_role | UPDATE
anon | DELETE
anon | INSERT
anon | REFERENCES
anon | SELECT
anon | TRIGGER
anon | TRUNCATE
anon | UPDATE
authenticated | DELETE
authenticated | INSERT
authenticated | REFERENCES
authenticated | SELECT
authenticated | TRIGGER
authenticated | TRUNCATE
authenticated | UPDATE
postgres | DELETE
postgres | INSERT
postgres | REFERENCES
postgres | SELECT
postgres | TRIGGER
postgres | TRUNCATE
postgres | UPDATE
service_role | DELETE
service_role | INSERT
service_role | REFERENCES
service_role | SELECT
service_role | TRIGGER
service_role | TRUNCATE
service_role | UPDATE
anon | DELETE
anon | INSERT
anon | REFERENCES
anon | SELECT
anon | TRIGGER
anon | TRUNCATE
anon | UPDATE
authenticated | DELETE
authenticated | INSERT
authenticated | REFERENCES
authenticated | SELECT
authenticated | TRIGGER
authenticated | TRUNCATE
authenticated | UPDATE
postgres | DELETE
postgres | INSERT
postgres | REFERENCES
postgres | SELECT
postgres | TRIGGER
postgres | TRUNCATE
postgres | UPDATE
service_role | DELETE
service_role | INSERT
service_role | REFERENCES
service_role | SELECT
service_role | TRIGGER
service_role | TRUNCATE
service_role | UPDATE
(84 rows)
cat > /tmp/rls.sql <<'SQL'
-- Enable RLS on the tenant-scoped tables.
ALTER TABLE public.org_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tickets ENABLE ROW LEVEL SECURITY;
-- Force RLS even for table owners (defense in depth).
ALTER TABLE public.org_members FORCE ROW LEVEL SECURITY;
ALTER TABLE public.tickets FORCE ROW LEVEL SECURITY;
-- Drop any pre-existing policies (idempotent re-run).
DROP POLICY IF EXISTS tickets_select_member ON public.tickets;
DROP POLICY IF EXISTS tickets_insert_admin ON public.tickets;
DROP POLICY IF EXISTS tickets_update_admin ON public.tickets;
DROP POLICY IF EXISTS tickets_delete_admin ON public.tickets;
DROP POLICY IF EXISTS org_members_select_same ON public.org_members;
-- ============================================================
-- tickets
-- ============================================================
-- Members of the org (any role) may SELECT.
CREATE POLICY tickets_select_member
ON public.tickets
FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1
FROM public.org_members om
WHERE om.org_id = tickets.org_id
AND om.user_id = auth.uid()
)
);
-- Only admins of that org may INSERT.
CREATE POLICY tickets_insert_admin
ON public.tickets
FOR INSERT
TO authenticated
WITH CHECK (
EXISTS (
SELECT 1
FROM public.org_members om
WHERE om.org_id = tickets.org_id
AND om.user_id = auth.uid()
AND om.role = 'admin'
)
);
-- Only admins of that org may UPDATE (existing + new row).
CREATE POLICY tickets_update_admin
ON public.tickets
FOR UPDATE
TO authenticated
USING (
EXISTS (
SELECT 1
FROM public.org_members om
WHERE om.org_id = tickets.org_id
AND om.user_id = auth.uid()
AND om.role = 'admin'
)
)
WITH CHECK (
EXISTS (
SELECT 1
FROM public.org_members om
WHERE om.org_id = tickets.org_id
AND om.user_id = auth.uid()
AND om.role = 'admin'
)
);
-- Only admins of that org may DELETE.
CREATE POLICY tickets_delete_admin
ON public.tickets
FOR DELETE
TO authenticated
USING (
EXISTS (
SELECT 1
FROM public.org_members om
WHERE om.org_id = tickets.org_id
AND om.user_id = auth.uid()
AND om.role = 'admin'
)
);
-- ============================================================
-- org_members
-- ============================================================
-- A row is visible only to users who share the same org.
CREATE POLICY org_members_select_same
ON public.org_members
FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1
FROM public.org_members me
WHERE me.org_id = org_members.org_id
AND me.user_id = auth.uid()
)
);
-- No INSERT/UPDATE/DELETE policies for authenticated -> service_role only.
-- (anon and authenticated have no write path; RLS is forced.)
SQL
echo "---- file ----"
wc -l /tmp/rls.sql---- file ---- 105 /tmp/rls.sql
export PGPASSWORD="$SUPABASE_DB_PASSWORD" psql "$SUPABASE_DB_URL" -v ON_ERROR_STOP=1 -f /tmp/rls.sql
ALTER TABLE ALTER TABLE ALTER TABLE ALTER TABLE DROP POLICY DROP POLICY DROP POLICY DROP POLICY DROP POLICY CREATE POLICY CREATE POLICY CREATE POLICY CREATE POLICY CREATE POLICY
psql:/tmp/rls.sql:10: NOTICE: policy "tickets_select_member" for relation "public.tickets" does not exist, skipping psql:/tmp/rls.sql:11: NOTICE: policy "tickets_insert_admin" for relation "public.tickets" does not exist, skipping psql:/tmp/rls.sql:12: NOTICE: policy "tickets_update_admin" for relation "public.tickets" does not exist, skipping psql:/tmp/rls.sql:13: NOTICE: policy "tickets_delete_admin" for relation "public.tickets" does not exist, skipping psql:/tmp/rls.sql:14: NOTICE: policy "org_members_select_same" for relation "public.org_members" does not exist, skipping
export PGPASSWORD="$SUPABASE_DB_PASSWORD"
psql "$SUPABASE_DB_URL" -c "SELECT schemaname, tablename, policyname, roles, cmd, length(qual) AS qual_len, length(with_check) AS wc_len FROM pg_policies WHERE schemaname='public' ORDER BY tablename, policyname;"
psql "$SUPABASE_DB_URL" -c "SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname IN ('orgs','org_members','tickets') AND relnamespace='public'::regnamespace;" schemaname | tablename | policyname | roles | cmd | qual_len | wc_len
------------+-------------+-------------------------+-----------------+--------+----------+--------
public | org_members | org_members_select_same | {authenticated} | SELECT | 116 |
public | tickets | tickets_delete_admin | {authenticated} | DELETE | 142 |
public | tickets | tickets_insert_admin | {authenticated} | INSERT | | 142
public | tickets | tickets_select_member | {authenticated} | SELECT | 112 |
public | tickets | tickets_update_admin | {authenticated} | UPDATE | 142 | 142
(5 rows)
relname | relrowsecurity | relforcerowsecurity
-------------+----------------+---------------------
org_members | t | t
orgs | f | f
tickets | t | t
(3 rows)
SUPABASE_URL="$SUPABASE_URL" SUPABASE_ANON_KEY="$SUPABASE_ANON_KEY" bash -c '
login() {
local email="$1" pw="$2"
curl -sS -X POST "$SUPABASE_URL/auth/v1/token?grant_type=password" \
-H "apikey: $SUPABASE_ANON_KEY" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$email\",\"password\":\"$pw\"}"
}
echo "== uma =="; login uma-admin@cari.dev "Passw0rd-uma-11" | jq -r ".access_token, .user.email"
echo "== vic =="; login vic-member@cari.dev "Passw0rd-vic-22" | jq -r ".access_token, .user.email"
echo "== wen =="; login wen-outsider@cari.dev "Passw0rd-wen-33" | jq -r ".access_token, .user.email"
'== uma == <jwt:authenticated> uma-admin@cari.dev == vic == <jwt:authenticated> vic-member@cari.dev == wen == <jwt:authenticated> wen-outsider@cari.dev
SUPABASE_URL="$SUPABASE_URL" SUPABASE_ANON_KEY="$SUPABASE_ANON_KEY" bash -c '
UMA=$(curl -sS -X POST "$SUPABASE_URL/auth/v1/token?grant_type=password" -H "apikey: $SUPABASE_ANON_KEY" -H "Content-Type: application/json" -d "{\"email\":\"uma-admin@cari.dev\",\"password\":\"Passw0rd-uma-11\"}" | jq -r .access_token)
VIC=$(curl -sS -X POST "$SUPABASE_URL/auth/v1/token?grant_type=password" -H "apikey: $SUPABASE_ANON_KEY" -H "Content-Type: application/json" -d "{\"email\":\"vic-member@cari.dev\",\"password\":\"Passw0rd-vic-22\"}" | jq -r .access_token)
WEN=$(curl -sS -X POST "$SUPABASE_URL/auth/v1/token?grant_type=password" -H "apikey: $SUPABASE_ANON_KEY" -H "Content-Type: application/json" -d "{\"email\":\"wen-outsider@cari.dev\",\"password\":\"Passw0rd-wen-33\"}" | jq -r .access_token)
rest() { # $1=jwt $2=label $3=path $4=method(default GET) $5=optional body
local jwt="$1" label="$2" path="$3" method="${4:-GET}" body="$5"
local hdr=()
hdr+=(-H "apikey: $SUPABASE_ANON_KEY")
[ -n "$jwt" ] && hdr+=(-H "Authorization: Bearer $jwt")
hdr+=(-H "Content-Type: application/json")
echo "--- $label ($method $path) ---"
if [ -n "$body" ]; then
curl -sS -X "$method" "$SUPABASE_URL/rest/v1$path" "${hdr[@]}" -d "$body" -w "\nHTTP %{http_code}\n"
else
curl -sS -X "$method" "$SUPABASE_URL/rest/v1$path" "${hdr[@]}" -w "\nHTTP %{http_code}\n"
fi
}
echo "#### TICKETS SELECT"
rest "" "anon" "/tickets?select=id,title" GET
rest "$WEN" "wen" "/tickets?select=id,title" GET
rest "$VIC" "vic" "/tickets?select=id,title" GET
rest "$UMA" "uma" "/tickets?select=id,title" GET
echo
echo "#### ORG_MEMBERS SELECT"
rest "" "anon" "/org_members?select=org_id,role" GET
rest "$UMA" "uma" "/org_members?select=org_id,role" GET
rest "$VIC" "vic" "/org_members?select=org_id,role" GET
rest "$WEN" "wen" "/org_members?select=org_id,role" GET
echo
echo "#### TICKETS INSERT (try as vic=member -> must fail; then uma=admin -> must succeed)"
ACME_ID=$(curl -sS -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" "$SUPABASE_URL/rest/v1/orgs?select=id,name&name=eq.Acme" | jq -r ".[0].id")
GLOBEX_ID=$(curl -sS -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $WEN" "$SUPABASE_URL/rest/v1/orgs?select=id,name&name=eq.Globex" | jq -r ".[0].id")
echo "Acme=$ACME_ID Globex=$GLOBEX_ID"
rest "$VIC" "vic-insert-Acme" "/tickets" POST "{\"org_id\":\"$ACME_ID\",\"title\":\"vic-should-fail\",\"status\":\"open\"}"
rest "$UMA" "uma-insert-Acme" "/tickets" POST "{\"org_id\":\"$ACME_ID\",\"title\":\"uma-insert-Acme\",\"status\":\"open\"}"
rest "$UMA" "uma-insert-Globex" "/tickets" POST "{\"org_id\":\"$GLOBEX_ID\",\"title\":\"uma-insert-Globex-FAIL\",\"status\":\"open\"}"
rest "$VIC" "vic-insert-Globex" "/tickets" POST "{\"org_id\":\"$GLOBEX_ID\",\"title\":\"vic-insert-Globex-FAIL\",\"status\":\"open\"}"
echo
echo "#### TICKETS UPDATE (vic should fail; uma should succeed on Acme, fail on Globex)"
ACME_T=$(curl -sS -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" "$SUPABASE_URL/rest/v1/tickets?select=id&org_id=eq.$ACME_ID&limit=1" | jq -r ".[0].id")
GLOBEX_T=$(curl -sS -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $WEN" "$SUPABASE_URL/rest/v1/tickets?select=id&org_id=eq.$GLOBEX_ID&limit=1" | jq -r ".[0].id")
echo "AcmeTicket=$ACME_T GlobexTicket=$GLOBEX_T"
rest "$VIC" "vic-update-Acme" "/tickets?id=eq.$ACME_T" PATCH "{\"status\":\"closed\"}"
rest "$UMA" "uma-update-Acme" "/tickets?id=eq.$ACME_T" PATCH "{\"status\":\"in_progress\"}"
rest "$UMA" "uma-update-Globex" "/tickets?id=eq.$GLOBEX_T" PATCH "{\"status\":\"closed\"}"
echo
echo "#### TICKETS DELETE (vic should fail; uma should succeed on Acme, fail on Globex)"
rest "$VIC" "vic-delete-Acme" "/tickets?id=eq.$ACME_T" DELETE
rest "$UMA" "uma-delete-Globex" "/tickets?id=eq.$GLOBEX_T" DELETE
'#### TICKETS SELECT
--- anon (GET /tickets?select=id,title) ---
[]
HTTP 200
--- wen (GET /tickets?select=id,title) ---
{"code":"42P17","details":null,"hint":null,"message":"infinite recursion detected in policy for relation \"org_members\""}
HTTP 500
--- vic (GET /tickets?select=id,title) ---
{"code":"42P17","details":null,"hint":null,"message":"infinite recursion detected in policy for relation \"org_members\""}
HTTP 500
--- uma (GET /tickets?select=id,title) ---
{"code":"42P17","details":null,"hint":null,"message":"infinite recursion detected in policy for relation \"org_members\""}
HTTP 500
#### ORG_MEMBERS SELECT
--- anon (GET /org_members?select=org_id,role) ---
[]
HTTP 200
--- uma (GET /org_members?select=org_id,role) ---
{"code":"42P17","details":null,"hint":null,"message":"infinite recursion detected in policy for relation \"org_members\""}
HTTP 500
--- vic (GET /org_members?select=org_id,role) ---
{"code":"42P17","details":null,"hint":null,"message":"infinite recursion detected in policy for relation \"org_members\""}
HTTP 500
--- wen (GET /org_members?select=org_id,role) ---
{"code":"42P17","details":null,"hint":null,"message":"infinite recursion detected in policy for relation \"org_members\""}
HTTP 500
#### TICKETS INSERT (try as vic=member -> must fail; then uma=admin -> must succeed)
Acme=f8b742f0-0eed-45e5-b117-78e93370f5f2 Globex=f8fa859d-4fb6-45f1-ac88-221e3832da2b
--- vic-insert-Acme (POST /tickets) ---
{"code":"42P17","details":null,"hint":null,"message":"infinite recursion detected in policy for relation \"org_members\""}
HTTP 500
--- uma-insert-Acme (POST /tickets) ---
{"code":"42P17","details":null,"hint":null,"message":"infinite recursion detected in policy for relation \"org_members\""}
HTTP 500
--- uma-insert-Globex (POST /tickets) ---
{"code":"42P17","details":null,"hint":null,"message":"infinite recursion detected in policy for relation \"org_members\""}
HTTP 500
--- vic-insert-Globex (POST /tickets) ---
{"code":"42P17","details":null,"hint":null,"message":"infinite recursion detected in policy for relation \"org_members\""}
HTTP 500
#### TICKETS UPDATE (vic should fail; uma should succeed on Acme, fail on Globex)
AcmeTicket= GlobexTicket=
--- vic-update-Acme (PATCH /tickets?id=eq.) ---
{"code":"42P17","details":null,"hint":null,"message":"infinite recursion detected in policy for relation \"org_members\""}
HTTP 500
--- uma-update-Acme (PATCH /tickets?id=eq.) ---
{"code":"42P17","details":null,"hint":null,"message":"infinite recursion detected in policy for relation \"org_members\""}
HTTP 500
--- uma-update-Globex (PATCH /tickets?id=eq.) ---
{"code":"42P17","details":null,"hint":null,"message":"infinite recursion detected in policy for relation \"org_members\""}
HTTP 500
#### TICKETS DELETE (vic should fail; uma should succeed on Acme, fail on Globex)
--- vic-delete-Acme (DELETE /tickets?id=eq.) ---
{"code":"42P17","details":null,"hint":null,"message":"infinite recursion detected in policy for relation \"org_members\""}
HTTP 500
--- uma-delete-Globex (DELETE /tickets?id=eq.) ---
{"code":"42P17","details":null,"hint":null,"message":"infinite recursion detected in policy for relation \"org_members\""}
HTTP 500
jq: error (at <stdin>:0): Cannot index object with number jq: error (at <stdin>:0): Cannot index object with number
cat > /tmp/rls_fix.sql <<'SQL'
-- ===========================================================
-- Helper: is the current auth user a member of the given org?
-- SECURITY DEFINER + its own search_path so it bypasses the
-- recursive RLS on org_members. The function is also marked
-- STABLE so the planner can inline it.
-- ===========================================================
CREATE OR REPLACE FUNCTION public.is_org_member(p_org_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
SELECT EXISTS (
SELECT 1 FROM public.org_members
WHERE org_id = p_org_id
AND user_id = auth.uid()
);
$$;
CREATE OR REPLACE FUNCTION public.is_org_admin(p_org_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
SELECT EXISTS (
SELECT 1 FROM public.org_members
WHERE org_id = p_org_id
AND user_id = auth.uid()
AND role = 'admin'
);
$$;
-- Lock down the helpers: only callable by authenticated users.
REVOKE ALL ON FUNCTION public.is_org_member(uuid) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.is_org_admin(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.is_org_member(uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.is_org_admin(uuid) TO authenticated;
-- ===========================================================
-- Drop the recursive policies and recreate them using the
-- SECURITY DEFINER helpers.
-- ===========================================================
DROP POLICY IF EXISTS tickets_select_member ON public.tickets;
DROP POLICY IF EXISTS tickets_insert_admin ON public.tickets;
DROP POLICY IF EXISTS tickets_update_admin ON public.tickets;
DROP POLICY IF EXISTS tickets_delete_admin ON public.tickets;
DROP POLICY IF EXISTS org_members_select_same ON public.org_members;
-- tickets: members may SELECT
CREATE POLICY tickets_select_member
ON public.tickets
FOR SELECT
TO authenticated
USING ( public.is_org_member(org_id) );
-- tickets: only admins may INSERT
CREATE POLICY tickets_insert_admin
ON public.tickets
FOR INSERT
TO authenticated
WITH CHECK ( public.is_org_admin(org_id) );
-- tickets: only admins may UPDATE (existing + new row must both be in their admin'd org)
CREATE POLICY tickets_update_admin
ON public.tickets
FOR UPDATE
TO authenticated
USING ( public.is_org_admin(org_id) )
WITH CHECK ( public.is_org_admin(org_id) );
-- tickets: only admins may DELETE
CREATE POLICY tickets_delete_admin
ON public.tickets
FOR DELETE
TO authenticated
USING ( public.is_org_admin(org_id) );
-- org_members: a row is visible only to users who share the same org
CREATE POLICY org_members_select_same
ON public.org_members
FOR SELECT
TO authenticated
USING ( public.is_org_member(org_id) );
SQL
echo "ok"ok
export PGPASSWORD="$SUPABASE_DB_PASSWORD" psql "$SUPABASE_DB_URL" -v ON_ERROR_STOP=1 -f /tmp/rls_fix.sql
CREATE FUNCTION CREATE FUNCTION REVOKE REVOKE GRANT GRANT DROP POLICY DROP POLICY DROP POLICY DROP POLICY DROP POLICY CREATE POLICY CREATE POLICY CREATE POLICY CREATE POLICY CREATE POLICY
SUPABASE_URL="$SUPABASE_URL" SUPABASE_ANON_KEY="$SUPABASE_ANON_KEY" bash -c '
UMA=$(curl -sS -X POST "$SUPABASE_URL/auth/v1/token?grant_type=password" -H "apikey: $SUPABASE_ANON_KEY" -H "Content-Type: application/json" -d "{\"email\":\"uma-admin@cari.dev\",\"password\":\"Passw0rd-uma-11\"}" | jq -r .access_token)
VIC=$(curl -sS -X POST "$SUPABASE_URL/auth/v1/token?grant_type=password" -H "apikey: $SUPABASE_ANON_KEY" -H "Content-Type: application/json" -d "{\"email\":\"vic-member@cari.dev\",\"password\":\"Passw0rd-vic-22\"}" | jq -r .access_token)
WEN=$(curl -sS -X POST "$SUPABASE_URL/auth/v1/token?grant_type=password" -H "apikey: $SUPABASE_ANON_KEY" -H "Content-Type: application/json" -d "{\"email\":\"wen-outsider@cari.dev\",\"password\":\"Passw0rd-wen-33\"}" | jq -r .access_token)
rest() { # jwt label path method body
local jwt="$1" label="$2" path="$3" method="${4:-GET}" body="$5"
local hdr=(-H "apikey: $SUPABASE_ANON_KEY")
[ -n "$jwt" ] && hdr+=(-H "Authorization: Bearer $jwt")
hdr+=(-H "Content-Type: application/json")
hdr+=(-H "Prefer: return=representation")
echo "--- $label ($method $path) ---"
if [ -n "$body" ]; then
curl -sS -X "$method" "$SUPABASE_URL/rest/v1$path" "${hdr[@]}" -d "$body" -w "\nHTTP %{http_code}\n"
else
curl -sS -X "$method" "$SUPABASE_URL/rest/v1$path" "${hdr[@]}" -w "\nHTTP %{http_code}\n"
fi
}
echo "#### TICKETS SELECT"
echo "anon:"; curl -sS -H "apikey: $SUPABASE_ANON_KEY" "$SUPABASE_URL/rest/v1/tickets?select=id,title&limit=20" -w " HTTP %{http_code}\n"
echo "wen:"; curl -sS -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $WEN" "$SUPABASE_URL/rest/v1/tickets?select=id,title&limit=20" -w " HTTP %{http_code}\n"
echo "vic:"; curl -sS -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $VIC" "$SUPABASE_URL/rest/v1/tickets?select=id,title&limit=20" -w " HTTP %{http_code}\n"
echo "uma:"; curl -sS -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" "$SUPABASE_URL/rest/v1/tickets?select=id,title&limit=20" -w " HTTP %{http_code}\n"
echo
echo "#### ORG_MEMBERS SELECT"
echo "anon:"; curl -sS -H "apikey: $SUPABASE_ANON_KEY" "$SUPABASE_URL/rest/v1/org_members?select=org_id,role&limit=20" -w " HTTP %{http_code}\n"
echo "wen:"; curl -sS -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $WEN" "$SUPABASE_URL/rest/v1/org_members?select=org_id,role&limit=20" -w " HTTP %{http_code}\n"
echo "vic:"; curl -sS -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $VIC" "$SUPABASE_URL/rest/v1/org_members?select=org_id,role&limit=20" -w " HTTP %{http_code}\n"
echo "uma:"; curl -sS -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" "$SUPABASE_URL/rest/v1/org_members?select=org_id,role&limit=20" -w " HTTP %{http_code}\n"
echo
echo "#### TICKETS INSERT"
ACME_ID=$(curl -sS -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" "$SUPABASE_URL/rest/v1/orgs?select=id,name&name=eq.Acme" | jq -r ".[0].id")
GLOBEX_ID=$(curl -sS -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $WEN" "$SUPABASE_URL/rest/v1/orgs?select=id,name&name=eq.Globex" | jq -r ".[0].id")
echo "Acme=$ACME_ID Globex=$GLOBEX_ID"
rest "$VIC" "vic-insert-Acme" "/tickets" POST "{\"org_id\":\"$ACME_ID\",\"title\":\"vic-should-fail\",\"status\":\"open\"}"
rest "$UMA" "uma-insert-Acme" "/tickets" POST "{\"org_id\":\"$ACME_ID\",\"title\":\"uma-insert-Acme\",\"status\":\"open\"}"
rest "$UMA" "uma-insert-Globex" "/tickets" POST "{\"org_id\":\"$GLOBEX_ID\",\"title\":\"uma-insert-Globex-FAIL\",\"status\":\"open\"}"
rest "$WEN" "wen-insert-Globex" "/tickets" POST "{\"org_id\":\"$GLOBEX_ID\",\"title\":\"wen-insert-Globex-FAIL\",\"status\":\"open\"}"
echo
echo "#### TICKETS UPDATE"
# use the Acme ticket um a just created (id > 3)
ACME_T=$(curl -sS -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" "$SUPABASE_URL/rest/v1/tickets?select=id&org_id=eq.$ACME_ID&order=id.desc&limit=1" | jq -r ".[0].id")
GLOBEX_T=$(curl -sS -H "ap#### TICKETS SELECT
anon:
[] HTTP 200
wen:
[{"id":4,"title":"Migrate DNS"},
{"id":5,"title":"Renew cert"}] HTTP 200
vic:
[{"id":1,"title":"Fix login"},
{"id":2,"title":"Update billing"},
{"id":3,"title":"Rotate keys"}] HTTP 200
uma:
[{"id":1,"title":"Fix login"},
{"id":2,"title":"Update billing"},
{"id":3,"title":"Rotate keys"}] HTTP 200
#### ORG_MEMBERS SELECT
anon:
[] HTTP 200
wen:
[{"org_id":"f8fa859d-4fb6-45f1-ac88-221e3832da2b","role":"member"}] HTTP 200
vic:
[{"org_id":"f8b742f0-0eed-45e5-b117-78e93370f5f2","role":"admin"},
{"org_id":"f8b742f0-0eed-45e5-b117-78e93370f5f2","role":"member"}] HTTP 200
uma:
[{"org_id":"f8b742f0-0eed-45e5-b117-78e93370f5f2","role":"admin"},
{"org_id":"f8b742f0-0eed-45e5-b117-78e93370f5f2","role":"member"}] HTTP 200
#### TICKETS INSERT
Acme=f8b742f0-0eed-45e5-b117-78e93370f5f2 Globex=f8fa859d-4fb6-45f1-ac88-221e3832da2b
--- vic-insert-Acme (POST /tickets) ---
{"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"tickets\""}
HTTP 403
--- uma-insert-Acme (POST /tickets) ---
[{"id":7,"org_id":"f8b742f0-0eed-45e5-b117-78e93370f5f2","title":"uma-insert-Acme","status":"open"}]
HTTP 201
--- uma-insert-Globex (POST /tickets) ---
{"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"tickets\""}
HTTP 403
--- wen-insert-Globex (POST /tickets) ---
{"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"tickets\""}
HTTP 403
#### TICKETS UPDATE
AcmeTicket=7 GlobexTicket=4
--- vic-update-Acme (PATCH /tickets?id=eq.7) ---
[]
HTTP 200
--- uma-update-Acme (PATCH /tickets?id=eq.7) ---
[{"id":7,"org_id":"f8b742f0-0eed-45e5-b117-78e93370f5f2","title":"uma-insert-Acme","status":"in_progress"}]
HTTP 200
--- uma-update-Globex (PATCH /tickets?id=eq.4) ---
[]
HTTP 200
--- wen-update-Globex (PATCH /tickets?id=eq.4) ---
[]
HTTP 200
#### TICKETS DELETE
NewId=
--- vic-delete-Acme (DELETE /tickets?id=eq.) ---
{"code":"22P02","details":null,"hint":null,"message":"invalid input syntax for type bigint: \"\""}
HTTP 400
--- uma-delete-Acme (DELETE /tickets?id=eq.) ---
{"code":"22P02","details":null,"hint":null,"message":"invalid input syntax for type bigint: \"\""}
HTTP 400
--- uma-delete-Globex (DELETE /tickets?id=eq.4) ---
[]
HTTP 200
#### ORG_MEMBERS write attempts (should all fail for authenticated; only service_role can write)
--- uma-insert-org_members (POST /org_members) ---
{"code":"22P02","details":null,"hint":null,"message":"invalid input syntax for type uuid: \"\""}
HTTP 400
--- vic-update-org_members (PATCH /org_members?org_id=eq.f8b742f0-0eed-45e5-b117-78e93370f5f2) ---
[]
HTTP 200
--- vic-delete-org_members (DELETE /org_members?org_id=eq.f8b742f0-0eed-45e5-b117-78e93370f5f2) ---
[]
HTTP 200
curl: (6) Could not resolve host: Bearer curl: (3) URL using bad/illegal format or missing URL curl: (3) URL using bad/illegal format or missing URL
SUPABASE_URL="$SUPABASE_URL" SUPABASE_ANON_KEY="$SUPABASE_ANON_KEY" bash -c '
UMA=$(curl -sS -X POST "$SUPABASE_URL/auth/v1/token?grant_type=password" -H "apikey: $SUPABASE_ANON_KEY" -H "Content-Type: application/json" -d "{\"email\":\"uma-admin@cari.dev\",\"password\":\"Passw0rd-uma-11\"}" | jq -r .access_token)
VIC=$(curl -sS -X POST "$SUPABASE_URL/auth/v1/token?grant_type=password" -H "apikey: $SUPABASE_ANON_KEY" -H "Content-Type: application/json" -d "{\"email\":\"vic-member@cari.dev\",\"password\":\"Passw0rd-vic-22\"}" | jq -r .access_token)
ACME_ID=$(curl -sS -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" "$SUPABASE_URL/rest/v1/orgs?select=id&name=eq.Acme" | jq -r ".[0].id")
VIC_UID=$(curl -sS -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" "$SUPABASE_URL/rest/v1/org_members?select=user_id&org_id=eq.$ACME_ID&limit=1" | jq -r ".[0].user_id")
echo "Acme=$ACME_ID VicUID=$VIC_UID"
echo "## authenticated INSERT org_members (must fail / RLS denies):"
curl -sS -X POST -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" -H "Content-Type: application/json" "$SUPABASE_URL/rest/v1/org_members" -d "{\"org_id\":\"$ACME_ID\",\"user_id\":\"$VIC_UID\",\"role\":\"admin\"}" -w " HTTP %{http_code}\n"
echo "## authenticated UPDATE org_members (must affect 0 rows):"
curl -sS -X PATCH -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" -H "Content-Type: application/json" "$SUPABASE_URL/rest/v1/org_members?org_id=eq.$ACME_ID" -d "{\"role\":\"admin\"}" -w " HTTP %{http_code}\n"
echo "## authenticated DELETE org_members (must affect 0 rows):"
curl -sS -X DELETE -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" "$SUPABASE_URL/rest/v1/org_members?org_id=eq.$ACME_ID" -w " HTTP %{http_code}\n"
echo
echo "## DELETE roundtrip on tickets:"
NEWID=$(curl -sS -X POST -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" -H "Content-Type: application/json" -H "Prefer: return=representation" "$SUPABASE_URL/rest/v1/tickets" -d "{\"org_id\":\"$ACME_ID\",\"title\":\"to-delete\",\"status\":\"open\"}" | jq -r ".[0].id")
echo "NewId=$NEWID"
echo "vic deletes:" ; curl -sS -X DELETE -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $VIC" "$SUPABASE_URL/rest/v1/tickets?id=eq.$NEWID" -w " HTTP %{http_code}\n"
echo "uma deletes:" ; curl -sS -X DELETE -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" "$SUPABASE_URL/rest/v1/tickets?id=eq.$NEWID" -w " HTTP %{http_code}\n"
echo "row gone?:"
curl -sS -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" "$SUPABASE_URL/rest/v1/tickets?id=eq.$NEWID&select=id" -w " HTTP %{http_code}\n"
'Acme=f8b742f0-0eed-45e5-b117-78e93370f5f2 VicUID=8e192155-324c-4c38-93fc-355fddea5188
## authenticated INSERT org_members (must fail / RLS denies):
{"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"org_members\""} HTTP 403
## authenticated UPDATE org_members (must affect 0 rows):
HTTP 204
## authenticated DELETE org_members (must affect 0 rows):
HTTP 204
## DELETE roundtrip on tickets:
NewId=11
vic deletes:
HTTP 204
uma deletes:
HTTP 204
row gone?:
[] HTTP 200
4 earlier narration turn(s) are in the raw transcript.
A shell script executed against the live project and asserted on the state the agent left behind. No model judged another model.
Raw transcript · Task definition and verifier · Every attempt