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 || apt list --installed 2>/dev/null | grep -i postgres
/usr/bin/psql
psql "$SUPABASE_DB_URL" -c "\dt public.*" -c "\d public.orgs" -c "\d public.org_members" -c "\d public.tickets"
List of tables
Schema | Name | Type | Owner
--------+-------------+-------+----------
public | org_members | table | postgres
public | orgs | table | postgres
public | tickets | table | postgres
(3 rows)
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
psql "$SUPABASE_DB_URL" -c "SELECT name, id FROM public.orgs ORDER BY name;" -c "SELECT om.org_id, o.name, om.user_id, om.role, u.email FROM public.org_members om JOIN public.orgs o ON o.id=om.org_id JOIN auth.users u ON u.id=om.user_id ORDER BY o.name, u.email;" -c "SELECT id, org_id, title, status FROM public.tickets ORDER BY id;"
name | id
--------+--------------------------------------
Acme | d1069d8d-177d-48ba-aacf-913abe4043d9
Globex | 0c1f8b7b-42d4-481c-9e16-bbc52024aed1
(2 rows)
org_id | name | user_id | role | email
--------------------------------------+--------+--------------------------------------+--------+-----------------------
d1069d8d-177d-48ba-aacf-913abe4043d9 | Acme | a61b33a7-74d4-4865-a0ef-fee4e0d639d7 | admin | uma-admin@cari.dev
d1069d8d-177d-48ba-aacf-913abe4043d9 | Acme | 4c5a1026-f7a4-47de-87a2-4a72612260c6 | member | vic-member@cari.dev
0c1f8b7b-42d4-481c-9e16-bbc52024aed1 | Globex | 15a72ae3-90c4-49d0-8260-4d37220fedc6 | member | wen-outsider@cari.dev
(3 rows)
id | org_id | title | status
----+--------------------------------------+----------------+--------
1 | d1069d8d-177d-48ba-aacf-913abe4043d9 | Fix login | open
2 | d1069d8d-177d-48ba-aacf-913abe4043d9 | Update billing | open
3 | d1069d8d-177d-48ba-aacf-913abe4043d9 | Rotate keys | open
4 | 0c1f8b7b-42d4-481c-9e16-bbc52024aed1 | Migrate DNS | open
5 | 0c1f8b7b-42d4-481c-9e16-bbc52024aed1 | Renew cert | open
(5 rows)
psql "$SUPABASE_DB_URL" -c "SELECT schemaname, tablename, rowsecurity FROM pg_tables WHERE schemaname='public';" -c "SELECT polname, polrelid::regclass, polcmd, polqual, polwithcheck FROM pg_policy ORDER BY polrelid::regclass::text;"
schemaname | tablename | rowsecurity ------------+-------------+------------- public | orgs | f public | org_members | f public | tickets | f (3 rows) polname | polrelid | polcmd | polqual | polwithcheck ---------+----------+--------+---------+-------------- (0 rows)
cat > /tmp/rls.sql <<'SQL'
BEGIN;
-- Enable RLS on tenant-scoped tables.
ALTER TABLE public.tickets ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.org_members ENABLE ROW LEVEL SECURITY;
-- Keep orgs readable to everyone as the task allows; do not enable RLS on it.
-- Helper: is the current user a member of the given org?
-- Returns boolean; SECURITY DEFINER so it runs with owner privileges and
-- avoids RLS recursion on org_members when called from policies on that table.
CREATE OR REPLACE FUNCTION public.is_org_member(org 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 = org AND user_id = auth.uid()
);
$$;
-- Helper: is the current user an admin of the given org?
CREATE OR REPLACE FUNCTION public.is_org_admin(org 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 = org AND user_id = auth.uid() AND role = 'admin'
);
$$;
-- Make sure no one can impersonate these helpers.
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, anon;
GRANT EXECUTE ON FUNCTION public.is_org_admin(uuid) TO authenticated, anon;
-- ===== tickets =====
-- Drop any pre-existing policies to keep this idempotent.
DROP POLICY IF EXISTS tickets_select ON public.tickets;
DROP POLICY IF EXISTS tickets_insert ON public.tickets;
DROP POLICY IF EXISTS tickets_update ON public.tickets;
DROP POLICY IF EXISTS tickets_delete ON public.tickets;
-- Any member of the org may read that org's tickets.
CREATE POLICY tickets_select ON public.tickets
FOR SELECT TO authenticated
USING (public.is_org_member(org_id));
-- Only admins may insert tickets; the new row's org must be one they admin.
CREATE POLICY tickets_insert ON public.tickets
FOR INSERT TO authenticated
WITH CHECK (public.is_org_admin(org_id));
-- Only admins may update tickets of their org.
-- USING checks the old row; WITH CHECK checks the new row (so role/org can't be escalated).
CREATE POLICY tickets_update ON public.tickets
FOR UPDATE TO authenticated
USING (public.is_org_admin(org_id))
WITH CHECK (public.is_org_admin(org_id));
-- Only admins may delete tickets of their org.
CREATE POLICY tickets_delete ON public.tickets
FOR DELETE TO authenticated
USING (public.is_org_admin(org_id));
-- ===== org_members =====
DROP POLICY IF EXISTS org_members_select ON public.org_members;
DROP POLICY IF EXISTS org_members_insert ON public.org_members;
DROP POLICY IF EXISTS org_members_update ON public.org_members;
DROP POLICY IF EXISTS org_members_delete ON public.org_members;
-- Members can see the roster of orgs they belong to (and nothing else).
CREATE POLICY org_members_select ON public.org_members
FOR SELECT TO authenticated
USING (public.is_org_member(org_id));
-- Lock down writes by default; service_role bypasses RLS so admin tooling still works.
CREATE POLICY org_members_no_write ON public.org_members
FOR ALL TO authenticated
USING (false) WITH CHECK (false);
COMMIT;
SQL
psql "$SUPABASE_DB_URL" -v ON_ERROR_STOP=1 -f /tmp/rls.sqlBEGIN ALTER TABLE ALTER TABLE CREATE FUNCTION CREATE FUNCTION REVOKE REVOKE GRANT GRANT DROP POLICY DROP POLICY DROP POLICY DROP POLICY CREATE POLICY CREATE POLICY CREATE POLICY CREATE POLICY DROP POLICY DROP POLICY DROP POLICY DROP POLICY CREATE POLICY CREATE POLICY COMMIT
psql:/tmp/rls.sql:46: NOTICE: policy "tickets_select" for relation "public.tickets" does not exist, skipping psql:/tmp/rls.sql:47: NOTICE: policy "tickets_insert" for relation "public.tickets" does not exist, skipping psql:/tmp/rls.sql:48: NOTICE: policy "tickets_update" for relation "public.tickets" does not exist, skipping psql:/tmp/rls.sql:49: NOTICE: policy "tickets_delete" for relation "public.tickets" does not exist, skipping psql:/tmp/rls.sql:74: NOTICE: policy "org_members_select" for relation "public.org_members" does not exist, skipping psql:/tmp/rls.sql:75: NOTICE: policy "org_members_insert" for relation "public.org_members" does not exist, skipping psql:/tmp/rls.sql:76: NOTICE: policy "org_members_update" for relation "public.org_members" does not exist, skipping psql:/tmp/rls.sql:77: NOTICE: policy "org_members_delete" for relation "public.org_members" does not exist, skipping
psql "$SUPABASE_DB_URL" -c "SELECT tablename, policyname, cmd, roles FROM pg_policies WHERE schemaname='public' ORDER BY tablename, cmd;" -c "SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname='public';"
tablename | policyname | cmd | roles
-------------+----------------------+--------+-----------------
org_members | org_members_no_write | ALL | {authenticated}
org_members | org_members_select | SELECT | {authenticated}
tickets | tickets_delete | DELETE | {authenticated}
tickets | tickets_insert | INSERT | {authenticated}
tickets | tickets_select | SELECT | {authenticated}
tickets | tickets_update | UPDATE | {authenticated}
(6 rows)
tablename | rowsecurity
-------------+-------------
tickets | t
orgs | f
org_members | t
(3 rows)
cat > /tmp/login.sh <<'BASH'
#!/usr/bin/env bash
# Login one of the test users and print their access_token (and the user id).
set -euo pipefail
EMAIL="$1"
PASSWORD="$2"
RESP=$(curl -s -X POST "$SUPABASE_URL/auth/v1/token?grant_type=password" \
-H "apikey: $SUPABASE_ANON_KEY" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}")
echo "$RESP"
BASH
chmod +x /tmp/login.sh
/tmp/login.sh uma-admin@cari.dev 'Passw0rd-uma-11'{"access_token":"<jwt:authenticated>","token_type":"bearer","expires_in":3600,"expires_at":1785038101,"refresh_token":"yrcavqpfxi2t","user":{"id":"a61b33a7-74d4-4865-a0ef-fee4e0d639d7","aud":"authenticated","role":"authenticated","email":"uma-admin@cari.dev","email_confirmed_at":"2026-07-26T02:54:24.758373Z","phone":"","confirmed_at":"2026-07-26T02:54:24.758373Z","last_sign_in_at":"2026-07-26T02:55:01.886669039Z","app_metadata":{"provider":"email","providers":["email"]},"user_metadata":{"email_verified":true},"identities":[{"identity_id":"bd822922-9c74-4697-b938-abd6470815e2","id":"a61b33a7-74d4-4865-a0ef-fee4e0d639d7","user_id":"a61b33a7-74d4-4865-a0ef-fee4e0d639d7","identity_data":{"email":"uma-admin@cari.dev","email_verified":false,"phone_verified":false,"sub":"a61b33a7-74d4-4865-a0ef-fee4e0d639d7"},"provider":"email","last_sign_in_at":"2026-07-26T02:54:24.75683Z","created_at":"2026-07-26T02:54:24.756871Z","updated_at":"2026-07-26T02:54:24.756871Z","email":"uma-admin@cari.dev"}],"created_at":"2026-07-26T02:54:24.755698Z","updated_at":"2026-07-26T02:55:01.893043Z","is_anonymous":false},"weak_password":null}
set -e
# Log all 3 test users in
UMA=$(/tmp/login.sh uma-admin@cari.dev 'Passw0rd-uma-11' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
VIC=$(/tmp/login.sh vic-member@cari.dev 'Passw0rd-vic-22' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
WEN=$(/tmp/login.sh wen-outsider@cari.dev 'Passw0rd-wen-33' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
echo "UMA=${UMA:0:30}..."; echo "VIC=${VIC:0:30}..."; echo "WEN=${WEN:0:30}..."
run() {
local label="$1"; shift
echo "----- $label -----"
curl -sS "$@" -w "\nHTTP %{http_code}\n"
}
ACME=d1069d8d-177d-48ba-aacf-913abe4043d9
GLOBEX=0c1f8b7b-42d4-481c-9e16-bbc52024aed1
echo "=== ANONYMOUS ==="
run "anon /tickets" "$SUPABASE_URL/rest/v1/tickets?select=id,org_id,title" -H "apikey: $SUPABASE_ANON_KEY"
run "anon /org_members" "$SUPABASE_URL/rest/v1/org_members?select=org_id,user_id,role" -H "apikey: $SUPABASE_ANON_KEY"
run "anon /orgs" "$SUPABASE_URL/rest/v1/orgs?select=id,name" -H "apikey: $SUPABASE_ANON_KEY"
echo "=== UMA (admin of Acme) ==="
run "uma /tickets" "$SUPABASE_URL/rest/v1/tickets?select=id,org_id,title&order=id" -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA"
run "uma /tickets?org_id=eq.Globex" "$SUPABASE_URL/rest/v1/tickets?select=id,org_id,title&org_id=eq.$GLOBEX" -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA"
run "uma /org_members (own)" "$SUPABASE_URL/rest/v1/org_members?select=org_id,user_id,role&order=user_id" -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA"
run "uma /org_members?org_id=eq.Globex" "$SUPABASE_URL/rest/v1/org_members?select=org_id,user_id,role&org_id=eq.$GLOBEX" -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA"
echo "=== VIC (member of Acme) ==="
run "vic /tickets" "$SUPABASE_URL/rest/v1/tickets?select=id,org_id,title&order=id" -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $VIC"
run "vic /tickets?org_id=eq.Globex" "$SUPABASE_URL/rest/v1/tickets?select=id,org_id,title&org_id=eq.$GLOBEX" -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $VIC"
run "vic /org_members (own)" "$SUPABASE_URL/rest/v1/org_members?select=org_id,user_id,role&order=user_id" -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $VIC"
run "vic /org_members?org_id=eq.Globex" "$SUPABASE_URL/rest/v1/org_members?select=org_id,user_id,role&org_id=eq.$GLOBEX" -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $VIC"
echo "=== WEN (member of Globex only) ==="
run "wen /tickets" "$SUPABASE_URL/rest/v1/tickets?select=id,org_id,title&order=id" -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $WEN"
run "wen /org_members" "$SUPABASE_URL/rest/v1/org_members?select=org_id,user_id,role&order=org_id" -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $WEN"
run "wen /org_members?org_id=eq.Acme" "$SUPABASE_URL/rest/v1/org_members?select=org_id,user_id,role&org_id=eq.$ACME" -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $WEN"UMA=eyJhbGciOiJFUzI1NiIsImtpZCI6Ij...
VIC=eyJhbGciOiJFUzI1NiIsImtpZCI6Ij...
WEN=eyJhbGciOiJFUzI1NiIsImtpZCI6Ij...
=== ANONYMOUS ===
----- anon /tickets -----
[]
HTTP 200
----- anon /org_members -----
[]
HTTP 200
----- anon /orgs -----
[{"id":"d1069d8d-177d-48ba-aacf-913abe4043d9","name":"Acme"},
{"id":"0c1f8b7b-42d4-481c-9e16-bbc52024aed1","name":"Globex"}]
HTTP 200
=== UMA (admin of Acme) ===
----- uma /tickets -----
[{"id":1,"org_id":"d1069d8d-177d-48ba-aacf-913abe4043d9","title":"Fix login"},
{"id":2,"org_id":"d1069d8d-177d-48ba-aacf-913abe4043d9","title":"Update billing"},
{"id":3,"org_id":"d1069d8d-177d-48ba-aacf-913abe4043d9","title":"Rotate keys"}]
HTTP 200
----- uma /tickets?org_id=eq.Globex -----
[]
HTTP 200
----- uma /org_members (own) -----
[{"org_id":"d1069d8d-177d-48ba-aacf-913abe4043d9","user_id":"4c5a1026-f7a4-47de-87a2-4a72612260c6","role":"member"},
{"org_id":"d1069d8d-177d-48ba-aacf-913abe4043d9","user_id":"a61b33a7-74d4-4865-a0ef-fee4e0d639d7","role":"admin"}]
HTTP 200
----- uma /org_members?org_id=eq.Globex -----
[]
HTTP 200
=== VIC (member of Acme) ===
----- vic /tickets -----
[{"id":1,"org_id":"d1069d8d-177d-48ba-aacf-913abe4043d9","title":"Fix login"},
{"id":2,"org_id":"d1069d8d-177d-48ba-aacf-913abe4043d9","title":"Update billing"},
{"id":3,"org_id":"d1069d8d-177d-48ba-aacf-913abe4043d9","title":"Rotate keys"}]
HTTP 200
----- vic /tickets?org_id=eq.Globex -----
[]
HTTP 200
----- vic /org_members (own) -----
[{"org_id":"d1069d8d-177d-48ba-aacf-913abe4043d9","user_id":"4c5a1026-f7a4-47de-87a2-4a72612260c6","role":"member"},
{"org_id":"d1069d8d-177d-48ba-aacf-913abe4043d9","user_id":"a61b33a7-74d4-4865-a0ef-fee4e0d639d7","role":"admin"}]
HTTP 200
----- vic /org_members?org_id=eq.Globex -----
[]
HTTP 200
=== WEN (member of Globex only) ===
----- wen /tickets -----
[{"id":4,"org_id":"0c1f8b7b-42d4-481c-9e16-bbc52024aed1","title":"Migrate DNS"},
{"id":5,"org_id":"0c1f8b7b-42d4-481c-9e16-bbc52024aed1","title":"Renew cert"}]
HTTP 200
----- wen /org_members -----
[{"org_id":"0c1f8b7b-42d4-481c-9e16-bbc52024aed1","user_id":"15a72ae3-90c4-49d0-8260-4d37220fedc6","role":"member"}]
HTTP 200
----- wen /org_members?org_id=eq.Acme -----
[]
HTTP 200
set -e
UMA=$(/tmp/login.sh uma-admin@cari.dev 'Passw0rd-uma-11' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
VIC=$(/tmp/login.sh vic-member@cari.dev 'Passw0rd-vic-22' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
WEN=$(/tmp/login.sh wen-outsider@cari.dev 'Passw0rd-wen-33' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
ACME=d1069d8d-177d-48ba-aacf-913abe4043d9
GLOBEX=0c1f8b7b-42d4-481c-9e16-bbc52024aed1
run() {
local label="$1"; shift
echo "----- $label -----"
curl -sS -o /tmp/body -w "HTTP %{http_code}\n" "$@"
head -c 400 /tmp/body; echo
}
echo "=== INSERT: UMA (admin) into Acme — should SUCCEED ==="
run "uma insert Acme" -X POST "$SUPABASE_URL/rest/v1/tickets" \
-H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" \
-H "Content-Type: application/json" -H "Prefer: return=minimal" \
-d "{\"org_id\":\"$ACME\",\"title\":\"RLS test - acme insert\",\"status\":\"open\"}"
echo "=== INSERT: VIC (member) into Acme — should be DENIED ==="
run "vic insert Acme" -X POST "$SUPABASE_URL/rest/v1/tickets" \
-H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $VIC" \
-H "Content-Type: application/json" -H "Prefer: return=minimal" \
-d "{\"org_id\":\"$ACME\",\"title\":\"RLS test - vic insert\",\"status\":\"open\"}"
echo "=== INSERT: UMA (admin of Acme) into Globex — should be DENIED ==="
run "uma insert Globex" -X POST "$SUPABASE_URL/rest/v1/tickets" \
-H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" \
-H "Content-Type: application/json" -H "Prefer: return=minimal" \
-d "{\"org_id\":\"$GLOBEX\",\"title\":\"RLS test - cross-tenant\",\"status\":\"open\"}"
echo "=== INSERT: WEN (member of Globex) into Globex — should be DENIED ==="
run "wen insert Globex" -X POST "$SUPABASE_URL/rest/v1/tickets" \
-H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $WEN" \
-H "Content-Type: application/json" -H "Prefer: return=minimal" \
-d "{\"org_id\":\"$GLOBEX\",\"title\":\"RLS test - wen insert\",\"status\":\"open\"}"
echo "=== INSERT: anon — should be DENIED ==="
run "anon insert" -X POST "$SUPABASE_URL/rest/v1/tickets" \
-H "apikey: $SUPABASE_ANON_KEY" \
-H "Content-Type: application/json" -H "Prefer: return=minimal" \
-d "{\"org_id\":\"$ACME\",\"title\":\"RLS test - anon\",\"status\":\"open\"}"
echo "=== UPDATE: VIC updating Acme ticket 1 — should be DENIED ==="
run "vic update 1" -X PATCH "$SUPABASE_URL/rest/v1/tickets?id=eq.1" \
-H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $VIC" \
-H "Content-Type: application/json" -H "Prefer: return=minimal" \
-d "{\"status\":\"closed\"}"
echo "=== UPDATE: UMA updating Acme ticket 1 — should SUCCEED ==="
run "uma update 1" -X PATCH "$SUPABASE_URL/rest/v1/tickets?id=eq.1" \
-H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" \
-H "Content-Type: application/json" -H "Prefer: return=minimal" \
-d "{\"status\":\"closed\"}"
echo "=== UPDATE: UMA trying to move Acme ticket into Globex — should be DENIED ==="
run "uma update 1 -> Globex" -X PATCH "$SUPABASE_URL/rest/v1/tickets?id=eq.1" \
-H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" \
-H "Content-Type: application/json" -H "Prefer: return=minimal" \
-d "{\"org_id\":\"$GLOBEX\"}"
echo "=== UPDATE: WEN trying to update Globex ticket 4 — should be DENIED ==="
run "wen update 4" -X PATCH "$SUPABASE_URL/rest/v1/tickets?id=eq.4" \
-H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $WEN" \
-H "Content-Type: application/json" -H "Prefer: return=minimal" \
-d "{\"status\":\"closed\"}"
echo "=== DELETE: VIC deleting Acme ticket 1 — should be DENIED ==="
run "vic delete 1" -X DELETE "$SUPABASE_URL/rest/v1/tickets?id=eq.1" \
-H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $VIC" \
-H "Prefer: return=minimal"
echo "=== DELETE: anon — should be DENIED ==="
run "anon delete 1" -X DELETE "$SUPABASE_URL/rest/v1/tickets?id=eq.1" \
=== INSERT: UMA (admin) into Acme — should SUCCEED ===
----- uma insert Acme -----
HTTP 201
=== INSERT: VIC (member) into Acme — should be DENIED ===
----- vic insert Acme -----
HTTP 403
{"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"tickets\""}
=== INSERT: UMA (admin of Acme) into Globex — should be DENIED ===
----- uma insert Globex -----
HTTP 403
{"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"tickets\""}
=== INSERT: WEN (member of Globex) into Globex — should be DENIED ===
----- wen insert Globex -----
HTTP 403
{"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"tickets\""}
=== INSERT: anon — should be DENIED ===
----- anon insert -----
HTTP 401
{"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"tickets\""}
=== UPDATE: VIC updating Acme ticket 1 — should be DENIED ===
----- vic update 1 -----
HTTP 204
=== UPDATE: UMA updating Acme ticket 1 — should SUCCEED ===
----- uma update 1 -----
HTTP 204
=== UPDATE: UMA trying to move Acme ticket into Globex — should be DENIED ===
----- uma update 1 -> Globex -----
HTTP 403
{"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"tickets\""}
=== UPDATE: WEN trying to update Globex ticket 4 — should be DENIED ===
----- wen update 4 -----
HTTP 204
=== DELETE: VIC deleting Acme ticket 1 — should be DENIED ===
----- vic delete 1 -----
HTTP 204
=== DELETE: anon — should be DENIED ===
----- anon delete 1 -----
HTTP 204
=== Final state of tickets (via service role so we can verify what really changed) ===
id | org_id | title | status
----+--------------------------------------+------------------------+--------
1 | d1069d8d-177d-48ba-aacf-913abe4043d9 | Fix login | closed
2 | d1069d8d-177d-48ba-aacf-913abe4043d9 | Update billing | open
3 | d1069d8d-177d-48ba-aacf-913abe4043d9 | Rotate keys | open
4 | 0c1f8b7b-42d4-481c-9e16-bbc52024aed1 | Migrate DNS | open
5 | 0c1f8b7b-42d4-481c-9e16-bbc52024aed1 | Renew cert | open
6 | d1069d8d-177d-48ba-aacf-913abe4043d9 | RLS test - acme insert | open
(6 rows)
set -e
# Verify that the PATCH/DELETE 204s really were 0 rows affected.
UMA=$(/tmp/login.sh uma-admin@cari.dev 'Passw0rd-uma-11' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
VIC=$(/tmp/login.sh vic-member@cari.dev 'Passw0rd-vic-22' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
echo "--- UMA attempting delete of ticket 1 (admin of Acme) — should succeed (204 with 1 row) ---"
curl -sS -X DELETE "$SUPABASE_URL/rest/v1/tickets?id=eq.1" \
-H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" \
-H "Prefer: return=representation" -w "\nHTTP %{http_code}\n"
echo "--- UMA delete of ticket 6 (the row UMA inserted) — succeed ---"
curl -sS -X DELETE "$SUPABASE_URL/rest/v1/tickets?id=eq.6" \
-H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA" \
-H "Prefer: return=representation" -w "\nHTTP %{http_code}\n"
echo "--- Restore ticket 1 status to 'open' so the original data state is preserved ---"
psql "$SUPABASE_DB_URL" -c "UPDATE public.tickets SET status='open' WHERE id=1;" -c "SELECT id, org_id, title, status FROM public.tickets ORDER BY id;"--- UMA attempting delete of ticket 1 (admin of Acme) — should succeed (204 with 1 row) ---
[{"id":1,"org_id":"d1069d8d-177d-48ba-aacf-913abe4043d9","title":"Fix login","status":"closed"}]
HTTP 200
--- UMA delete of ticket 6 (the row UMA inserted) — succeed ---
[{"id":6,"org_id":"d1069d8d-177d-48ba-aacf-913abe4043d9","title":"RLS test - acme insert","status":"open"}]
HTTP 200
--- Restore ticket 1 status to 'open' so the original data state is preserved ---
UPDATE 0
id | org_id | title | status
----+--------------------------------------+----------------+--------
2 | d1069d8d-177d-48ba-aacf-913abe4043d9 | Update billing | open
3 | d1069d8d-177d-48ba-aacf-913abe4043d9 | Rotate keys | open
4 | 0c1f8b7b-42d4-481c-9e16-bbc52024aed1 | Migrate DNS | open
5 | 0c1f8b7b-42d4-481c-9e16-bbc52024aed1 | Renew cert | open
(4 rows)
psql "$SUPABASE_DB_URL" -c "INSERT INTO public.tickets (id, org_id, title, status) VALUES (1, 'd1069d8d-177d-48ba-aacf-913abe4043d9', 'Fix login', 'open');" -c "SELECT id, org_id, title, status FROM public.tickets ORDER BY id;" -c "SELECT setval(pg_get_serial_sequence('public.tickets','id'), (SELECT MAX(id) FROM public.tickets));"INSERT 0 1
id | org_id | title | status
----+--------------------------------------+----------------+--------
1 | d1069d8d-177d-48ba-aacf-913abe4043d9 | Fix login | open
2 | d1069d8d-177d-48ba-aacf-913abe4043d9 | Update billing | open
3 | d1069d8d-177d-48ba-aacf-913abe4043d9 | Rotate keys | open
4 | 0c1f8b7b-42d4-481c-9e16-bbc52024aed1 | Migrate DNS | open
5 | 0c1f8b7b-42d4-481c-9e16-bbc52024aed1 | Renew cert | open
(5 rows)
setval
--------
5
(1 row)
set -e
UMA=$(/tmp/login.sh uma-admin@cari.dev 'Passw0rd-uma-11' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
VIC=$(/tmp/login.sh vic-member@cari.dev 'Passw0rd-vic-22' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
WEN=$(/tmp/login.sh wen-outsider@cari.dev 'Passw0rd-wen-33' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
ACME=d1069d8d-177d-48ba-aacf-913abe4043d9
GLOBEX=0c1f8b7b-42d4-481c-9e16-bbc52024aed1
echo "============================================================"
echo "FINAL END-TO-END VERIFICATION (data already restored above)"
echo "============================================================"
printf "\n[1] ANON sees nothing on /tickets or /org_members, but can read /orgs:\n"
echo " anon /tickets: $(curl -sS "$SUPABASE_URL/rest/v1/tickets?select=id" -H "apikey: $SUPABASE_ANON_KEY")"
echo " anon /org_members:$(curl -sS "$SUPABASE_URL/rest/v1/org_members?select=org_id" -H "apikey: $SUPABASE_ANON_KEY")"
echo " anon /orgs: $(curl -sS "$SUPABASE_URL/rest/v1/orgs?select=name" -H "apikey: $SUPABASE_ANON_KEY")"
printf "\n[2] UMA (admin/Acme) sees Acme tickets only, sees only Acme members:\n"
echo " uma /tickets: $(curl -sS "$SUPABASE_URL/rest/v1/tickets?select=id,title" -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA")"
echo " uma /org_members: $(curl -sS "$SUPABASE_URL/rest/v1/org_members?select=org_id,role" -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $UMA")"
printf "\n[3] VIC (member/Acme) sees Acme tickets (read-only), sees only Acme members:\n"
echo " vic /tickets: $(curl -sS "$SUPABASE_URL/rest/v1/tickets?select=id,title" -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $VIC")"
echo " vic /org_members: $(curl -sS "$SUPABASE_URL/rest/v1/org_members?select=org_id,role" -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $VIC")"
printf " vic PATCH (must be no-op):\n"
curl -sS -X PATCH "$SUPABASE_URL/rest/v1/tickets?id=eq.2&select=id,status" \
-H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $VIC" \
-H "Content-Type: application/json" -H "Prefer: return=representation" \
-d '{"status":"closed"}' -w " HTTP %{http_code}\n"
printf "\n[4] WEN (member/Globex) sees Globex tickets only, sees only Globex members:\n"
echo " wen /tickets: $(curl -sS "$SUPABASE_URL/rest/v1/tickets?select=id,title" -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $WEN")"
echo " wen /org_members: $(curl -sS "$SUPABASE_URL/rest/v1/org_members?select=org_id,role" -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $WEN")"
printf "\n[5] Final ticket state (must match original):\n"
psql "$SUPABASE_DB_URL" -c "SELECT id, org_id, title, status FROM public.tickets ORDER BY id;"
printf "\n[6] RLS policies in place:\n"
psql "$SUPABASE_DB_URL" -c "SELECT tablename, policyname, cmd, roles FROM pg_policies WHERE schemaname='public' ORDER BY tablename, cmd;"
psql "$SUPABASE_DB_URL" -c "SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname='public' ORDER BY tablename;"============================================================
FINAL END-TO-END VERIFICATION (data already restored above)
============================================================
[1] ANON sees nothing on /tickets or /org_members, but can read /orgs:
anon /tickets: []
anon /org_members:[]
anon /orgs: [{"name":"Acme"},
{"name":"Globex"}]
[2] UMA (admin/Acme) sees Acme tickets only, sees only Acme members:
uma /tickets: [{"id":2,"title":"Update billing"},
{"id":3,"title":"Rotate keys"},
{"id":1,"title":"Fix login"}]
uma /org_members: [{"org_id":"d1069d8d-177d-48ba-aacf-913abe4043d9","role":"admin"},
{"org_id":"d1069d8d-177d-48ba-aacf-913abe4043d9","role":"member"}]
[3] VIC (member/Acme) sees Acme tickets (read-only), sees only Acme members:
vic /tickets: [{"id":2,"title":"Update billing"},
{"id":3,"title":"Rotate keys"},
{"id":1,"title":"Fix login"}]
vic /org_members: [{"org_id":"d1069d8d-177d-48ba-aacf-913abe4043d9","role":"admin"},
{"org_id":"d1069d8d-177d-48ba-aacf-913abe4043d9","role":"member"}]
vic PATCH (must be no-op):
[] HTTP 200
[4] WEN (member/Globex) sees Globex tickets only, sees only Globex members:
wen /tickets: [{"id":4,"title":"Migrate DNS"},
{"id":5,"title":"Renew cert"}]
wen /org_members: [{"org_id":"0c1f8b7b-42d4-481c-9e16-bbc52024aed1","role":"member"}]
[5] Final ticket state (must match original):
id | org_id | title | status
----+--------------------------------------+----------------+--------
1 | d1069d8d-177d-48ba-aacf-913abe4043d9 | Fix login | open
2 | d1069d8d-177d-48ba-aacf-913abe4043d9 | Update billing | open
3 | d1069d8d-177d-48ba-aacf-913abe4043d9 | Rotate keys | open
4 | 0c1f8b7b-42d4-481c-9e16-bbc52024aed1 | Migrate DNS | open
5 | 0c1f8b7b-42d4-481c-9e16-bbc52024aed1 | Renew cert | open
(5 rows)
[6] RLS policies in place:
tablename | policyname | cmd | roles
-------------+----------------------+--------+-----------------
org_members | org_members_no_write | ALL | {authenticated}
org_members | org_members_select | SELECT | {authenticated}
tickets | tickets_delete | DELETE | {authenticated}
tickets | tickets_insert | INSERT | {authenticated}
tickets | tickets_select | SELECT | {authenticated}
tickets | tickets_update | UPDATE | {authenticated}
(6 rows)
tablename | rowsecurity
-------------+-------------
org_members | t
orgs | f
tickets | t
(3 rows)
5 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