echo "=== Env vars ===" && env | grep -i supabase | sed 's/=.*/=<redacted>/' && echo "=== Tools ===" && which psql supabase curl jq 2>/dev/null; echo "---"; psql --version 2>/dev/null; curl --version 2>/dev/null | head -1; jq --version 2>/dev/null
=== Env vars === SUPABASE_DB_PASSWORD=<redacted> SUPABASE_DB_URL=<redacted> SUPABASE_URL=<redacted> SUPABASE_SERVICE_ROLE_KEY=<redacted> SUPABASE_PROJECT_REF=<redacted> SUPABASE_ANON_KEY=<redacted> === Tools === /usr/bin/psql /usr/bin/supabase /usr/bin/curl /usr/bin/jq --- psql (PostgreSQL) 18.4 (Debian 18.4-1.pgdg12+1) curl 7.88.1 (x86_64-pc-linux-gnu) libcurl/7.88.1 OpenSSL/3.0.20 zlib/1.2.13 brotli/1.0.9 zstd/1.5.4 libidn2/2.3.3 libpsl/0.21.2 (+libidn2/2.3.3) libssh2/1.10.0 nghttp2/1.52.0 librtmp/2.3 OpenLDAP/2.5.13 jq-1.6
echo "URL=$SUPABASE_URL"; echo "REF=$SUPABASE_PROJECT_REF"; echo "ANON=${SUPABASE_ANON_KEY:0:20}..."; echo "SVC=${SUPABASE_SERVICE_ROLE_KEY:0:20}..."; echo "DBURL set: ${SUPABASE_DB_URL:+yes}"URL=https://<ref>.supabase.co REF=<ref> ANON=eyJhbGciOiJIUzI1NiIs... SVC=eyJhbGciOiJIUzI1NiIs... DBURL set: yes
psql "$SUPABASE_DB_URL" -c "\d orgs" -c "\d org_members" -c "\d tickets" 2>&1 | head -80
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 schemaname, tablename, rowsecurity FROM pg_tables WHERE schemaname='public' AND tablename IN ('orgs','org_members','tickets');" -c "SELECT * FROM pg_policies WHERE schemaname='public' AND tablename IN ('orgs','org_members','tickets');" 2>&1schemaname | tablename | rowsecurity ------------+-------------+------------- public | org_members | f public | orgs | f public | tickets | f (3 rows) schemaname | tablename | policyname | permissive | roles | cmd | qual | with_check ------------+-----------+------------+------------+-------+-----+------+------------ (0 rows)
psql "$SUPABASE_DB_URL" -c "SELECT id, name FROM public.orgs ORDER BY name;" -c "SELECT om.org_id, o.name, om.user_id, om.role FROM public.org_members om JOIN public.orgs o ON o.id=om.org_id ORDER BY o.name, om.role;" -c "SELECT t.org_id, 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;" 2>&1
id | name
--------------------------------------+--------
24b8a4b7-8c1c-422f-b564-92ba3ae4cafb | Acme
40038489-d76d-4eb9-9677-f60dad3db1ae | Globex
(2 rows)
org_id | name | user_id | role
--------------------------------------+--------+--------------------------------------+--------
24b8a4b7-8c1c-422f-b564-92ba3ae4cafb | Acme | 63f67cd0-f55c-4015-b656-cb0fcc1e4046 | admin
24b8a4b7-8c1c-422f-b564-92ba3ae4cafb | Acme | 7ac9033a-eec1-4342-bef5-68acb1e18624 | member
40038489-d76d-4eb9-9677-f60dad3db1ae | Globex | c33b20b1-b50e-44e7-aaa5-b2be978f2d59 | member
(3 rows)
org_id | name | id | title | status
--------------------------------------+--------+----+----------------+--------
24b8a4b7-8c1c-422f-b564-92ba3ae4cafb | Acme | 1 | Fix login | open
24b8a4b7-8c1c-422f-b564-92ba3ae4cafb | Acme | 2 | Update billing | open
24b8a4b7-8c1c-422f-b564-92ba3ae4cafb | Acme | 3 | Rotate keys | open
40038489-d76d-4eb9-9677-f60dad3db1ae | Globex | 4 | Migrate DNS | open
40038489-d76d-4eb9-9677-f60dad3db1ae | Globex | 5 | Renew cert | open
(5 rows)
psql "$SUPABASE_DB_URL" -c "SELECT id, email, raw_user_meta_data FROM auth.users WHERE email IN ('uma-admin@cari.dev','vic-member@cari.dev','wen-outsider@cari.dev');" 2>&1 id | email | raw_user_meta_data
--------------------------------------+-----------------------+--------------------------
63f67cd0-f55c-4015-b656-cb0fcc1e4046 | uma-admin@cari.dev | {"email_verified": true}
7ac9033a-eec1-4342-bef5-68acb1e18624 | vic-member@cari.dev | {"email_verified": true}
c33b20b1-b50e-44e7-aaa5-b2be978f2d59 | wen-outsider@cari.dev | {"email_verified": true}
(3 rows)
cat > /tmp/rls.sql <<'EOF'
-- ============================================================
-- Multi-tenant Row-Level Security for orgs / org_members / tickets
-- ============================================================
-- Helper: is the current JWT user a member of the given org?
-- SECURITY DEFINER so the check bypasses RLS on org_members
-- (prevents infinite recursion) and runs with table-owner rights.
CREATE OR REPLACE FUNCTION public.is_member_of(p_org uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT EXISTS (
SELECT 1
FROM public.org_members om
WHERE om.org_id = p_org
AND om.user_id = auth.uid()
);
$$;
-- Helper: is the current JWT user an ADMIN of the given org?
CREATE OR REPLACE FUNCTION public.is_admin_of(p_org uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT EXISTS (
SELECT 1
FROM public.org_members om
WHERE om.org_id = p_org
AND om.user_id = auth.uid()
AND om.role = 'admin'
);
$$;
-- Allow anon + authenticated to call the helpers
GRANT EXECUTE ON FUNCTION public.is_member_of(uuid) TO anon, authenticated;
GRANT EXECUTE ON FUNCTION public.is_admin_of(uuid) TO anon, authenticated;
-- Make sure the data roles can read/write the tables (RLS then gates rows)
GRANT SELECT ON public.orgs TO anon, authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.org_members TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.tickets TO authenticated;
-- ============================================================
-- orgs: keep readable by authenticated users (do not break members)
-- ============================================================
ALTER TABLE public.orgs ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS orgs_select_authenticated ON public.orgs;
CREATE POLICY orgs_select_authenticated
ON public.orgs
FOR SELECT
TO authenticated
USING (true);
-- ============================================================
-- org_members: visible ONLY to members of that same org
-- ============================================================
ALTER TABLE public.org_members ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS org_members_select_own_org ON public.org_members;
CREATE POLICY org_members_select_own_org
ON public.org_members
FOR SELECT
TO authenticated
USING (public.is_member_of(org_id));
-- ============================================================
-- tickets: members read; only admins write, all within their org
-- ============================================================
ALTER TABLE public.tickets ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tickets_select_member ON public.tickets;
CREATE POLICY tickets_select_member
ON public.tickets
FOR SELECT
TO authenticated
USING (public.is_member_of(org_id));
DROP POLICY IF EXISTS tickets_insert_admin ON public.tickets;
CREATE POLICY tickets_insert_admin
ON public.tickets
FOR INSERT
TO authenticated
WITH CHECK (public.is_admin_of(org_id));
DROP POLICY IF EXISTS tickets_update_admin ON public.tickets;
CREATE POLICY tickets_update_admin
ON public.tickets
FOR UPDATE
TO authenticated
USING (public.is_admin_of(org_id))
WITH CHECK (public.is_admin_of(org_id));
DROP POLICY IF EXISTS tickets_delete_admin ON public.tickets;
CREATE POLICY tickets_delete_admin
ON public.tickets
FOR DELETE
TO authenticated
USING (public.is_admin_of(org_id));
EOF
psql "$SUPABASE_DB_URL" -v ON_ERROR_STOP=1 -f /tmp/rls.sql 2>&1CREATE FUNCTION CREATE FUNCTION GRANT GRANT GRANT GRANT GRANT ALTER TABLE psql:/tmp/rls.sql:54: NOTICE: policy "orgs_select_authenticated" for relation "public.orgs" does not exist, skipping DROP POLICY CREATE POLICY ALTER TABLE psql:/tmp/rls.sql:66: NOTICE: policy "org_members_select_own_org" for relation "public.org_members" does not exist, skipping DROP POLICY CREATE POLICY ALTER TABLE psql:/tmp/rls.sql:78: NOTICE: policy "tickets_select_member" for relation "public.tickets" does not exist, skipping DROP POLICY CREATE POLICY psql:/tmp/rls.sql:85: NOTICE: policy "tickets_insert_admin" for relation "public.tickets" does not exist, skipping DROP POLICY CREATE POLICY psql:/tmp/rls.sql:92: NOTICE: policy "tickets_update_admin" for relation "public.tickets" does not exist, skipping DROP POLICY CREATE POLICY psql:/tmp/rls.sql:100: NOTICE: policy "tickets_delete_admin" for relation "public.tickets" does not exist, skipping DROP POLICY CREATE POLICY
psql "$SUPABASE_DB_URL" -c "SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname='public' AND tablename IN ('orgs','org_members','tickets');" -c "SELECT tablename, policyname, cmd, roles, qual, with_check FROM pg_policies WHERE schemaname='public' ORDER BY tablename, cmd;" 2>&1 tablename | rowsecurity
-------------+-------------
org_members | t
orgs | t
tickets | t
(3 rows)
tablename | policyname | cmd | roles | qual | with_check
-------------+----------------------------+--------+-----------------+----------------------+---------------------
org_members | org_members_select_own_org | SELECT | {authenticated} | is_member_of(org_id) |
orgs | orgs_select_authenticated | SELECT | {authenticated} | true |
tickets | tickets_delete_admin | DELETE | {authenticated} | is_admin_of(org_id) |
tickets | tickets_insert_admin | INSERT | {authenticated} | | is_admin_of(org_id)
tickets | tickets_select_member | SELECT | {authenticated} | is_member_of(org_id) |
tickets | tickets_update_admin | UPDATE | {authenticated} | is_admin_of(org_id) | is_admin_of(org_id)
(6 rows)
cd /tmp
get_jwt () {
local email="$1" pass="$2"
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\":\"$pass\"}" | jq -r '.access_token // (. // empty)'
}
echo "UMA=$(get_jwt 'uma-admin@cari.dev' 'Passw0rd-uma-11')" > /tmp/tokens.env
echo "VIC=$(get_jwt 'vic-member@cari.dev' 'Passw0rd-vic-22')" >> /tmp/tokens.env
echo "WEN=$(get_jwt 'wen-outsider@cari.dev' 'Passw0rd-wen-33')" >> /tmp/tokens.env
cat /tmp/tokens.env | sed 's/=.*/=<jwt>/'
echo "--- decode uma role/uid ---"
UMA=$(grep UMA /tmp/tokens.env | cut -d= -f2-)
echo "$UMA" | cut -d. -f2 | base64 -d 2>/dev/null | jq -c '{sub: .sub, email: .email, role: .role}' 2>/dev/null || echo "decoded"UMA=<jwt>
VIC=<jwt>
WEN=<jwt>
--- decode uma role/uid ---
{"sub":"63f67cd0-f55c-4015-b656-cb0fcc1e4046","email":"uma-admin@cari.dev","role":"authenticated"}
cd /tmp
source /tmp/tokens.env
ACME=24b8a4b7-8c1c-422f-b564-92ba3ae4cafb
GLOBEX=40038489-d76d-4eb9-9677-f60dad3db1ae
api () { # method path token [datafile]
local m="$1" p="$2" tok="$3" d="${4:-}"
local auth="-H apikey:$SUPABASE_ANON_KEY"
[ -n "$tok" ] && auth="$auth -H authorization:Bearer $tok"
if [ -n "$d" ]; then
curl -s -X "$m" "$SUPABASE_URL/rest/v1/$p" $auth -H "Content-Type: application/json" --data-binary "@$d"
else
curl -s -X "$m" "$SUPABASE_URL/rest/v1/$p" $auth -H "Prefer: return=representation"
fi
}
section () { echo; echo "===== $1 ====="; }
section "ANON: orgs"
api GET "orgs" "" | jq 'length'
section "ANON: tickets"
api GET "tickets" "" | jq 'length'
section "ANON: org_members"
api GET "org_members" "" | jq 'length'
section "UMA (Acme admin): tickets count"
api GET "tickets" "$UMA" | jq 'length, [.[].org_id] | unique'
section "UMA: org_members (own)"
api GET "org_members?org_id=eq.$ACME" "$UMA" | jq 'length, [.[].role]'
section "UMA: tickets in Globex (should be 0)"
api GET "tickets?org_id=eq.$GLOBEX" "$UMA" | jq 'length'
section "UMA: org_members Globex (should be 0)"
api GET "org_members?org_id=eq.$GLOBEX" "$UMA" | jq 'length'===== ANON: orgs ===== 0 ===== ANON: tickets ===== 0 ===== ANON: org_members ===== 0 ===== UMA (Acme admin): tickets count ===== ===== UMA: org_members (own) ===== 4 ===== UMA: tickets in Globex (should be 0) ===== 4 ===== UMA: org_members Globex (should be 0) ===== 4
jq: error (at <stdin>:0): Cannot iterate over number (4) jq: error (at <stdin>:0): Cannot index string with string "role"
cd /tmp source /tmp/tokens.env ACME=24b8a4b7-8c1c-422f-b564-92ba3ae4cafb GLOBEX=40038489-d76d-4eb9-9677-f60dad3db1ae echo "--- RAW UMA tickets ---" curl -s -i -X GET "$SUPABASE_URL/rest/v1/tickets" -H "apikey: $SUPABASE_ANON_KEY" -H "authorization: Bearer $UMA" | head -40 echo echo "--- RAW UMA tickets Globex ---" curl -s -X GET "$SUPABASE_URL/rest/v1/tickets?org_id=eq.$GLOBEX" -H "apikey: $SUPABASE_ANON_KEY" -H "authorization: Bearer $UMA" echo
--- RAW UMA tickets ---
HTTP/2 200
date: Sun, 26 Jul 2026 02:57:24 GMT
content-type: application/json; charset=utf-8
content-range: 0-2/*
cf-ray: a21022ff7d1d18db-FRA
cf-cache-status: DYNAMIC
set-cookie: __cf_bm=EXNpXcqfAwCHGC8wxgLnF62CGi9UnonS0xpiAsp7RnA-1785034644.395201-1.0.1.1-xnOMjNsTjaK1sS2qf7Wz8ShEYdqHsJmi5Vs_ci1ANRV69eH4y33ZnhuTs0LyhW46qk.zTCuwYOXHtud.ltZaytSzlVD_BOOZZOOCW4H8mCIY8qT.LVwE5XEDMtqiUVNi; HttpOnly; SameSite=None; Secure; Path=/; Domain=supabase.co; Expires=Sun, 26 Jul 2026 03:27:24 GMT
content-location: /tickets
server: cloudflare
strict-transport-security: max-age=31536000; includeSubDomains; preload
vary: Accept-Encoding
x-content-type-options: nosniff
content-profile: public
sb-gateway-version: 1
sb-project-ref: <ref>
sb-request-id: 019f9c5b-1baf-7500-aeac-3c75b2487388
x-envoy-attempt-count: 1
x-envoy-upstream-service-time: 22
alt-svc: h3=":443"; ma=86400
[{"id":1,"org_id":"24b8a4b7-8c1c-422f-b564-92ba3ae4cafb","title":"Fix login","status":"open"},
{"id":2,"org_id":"24b8a4b7-8c1c-422f-b564-92ba3ae4cafb","title":"Update billing","status":"open"},
{"id":3,"org_id":"24b8a4b7-8c1c-422f-b564-92ba3ae4cafb","title":"Rotate keys","status":"open"}]
--- RAW UMA tickets Globex ---
[]
cd /tmp source /tmp/tokens.env ACME=24b8a4b7-8c1c-422f-b564-92ba3ae4cafb GLOBEX=40038489-d76d-4eb9-9677-f60dad3db1ae H=" -H apikey:$SUPABASE_ANON_KEY" echo "### org_members as UMA (Acme admin) - expect 2 Acme rows only" curl -s $H -H "authorization: Bearer $UMA" "$SUPABASE_URL/rest/v1/org_members" | jq 'length, [.[].org_id]|unique' echo "### org_members as VIC (Acme member) - expect 2 Acme rows" curl -s $H -H "authorization: Bearer $VIC" "$SUPABASE_URL/rest/v1/org_members" | jq 'length, [.[].org_id]|unique' echo "### org_members as WEN (Globex member) - expect 1 Globex row" curl -s $H -H "authorization: Bearer $WEN" "$SUPABASE_URL/rest/v1/org_members" | jq 'length, [.[].org_id]|unique' echo "### org_members as ANON - expect 0" curl -s $H "$SUPABASE_URL/rest/v1/org_members" | jq 'length'
### org_members as UMA (Acme admin) - expect 2 Acme rows only ### org_members as VIC (Acme member) - expect 2 Acme rows ### org_members as WEN (Globex member) - expect 1 Globex row ### org_members as ANON - expect 0 0
jq: error (at <stdin>:1): Cannot iterate over number (2) jq: error (at <stdin>:1): Cannot iterate over number (2) jq: error (at <stdin>:0): Cannot iterate over number (1)
cd /tmp
source /tmp/tokens.env
ACME=24b8a4b7-8c1c-422f-b564-92ba3ae4cafb
GLOBEX=40038489-d76d-4eb9-9677-f60dad3db1ae
H=" -H apikey:$SUPABASE_ANON_KEY"
show () { local name="$1" tok="$2"; echo "### $name:"; curl -s $H -H "authorization: Bearer $tok" "$SUPABASE_URL/rest/v1/org_members" | jq -c '{count: length, orgs: ([.[].org_id]|unique)}'; }
show "UMA (Acme admin)" "$UMA"
show "VIC (Acme member)" "$VIC"
show "WEN (Globex member)" "$WEN"
echo "### ANON:"; curl -s $H "$SUPABASE_URL/rest/v1/org_members" | jq -c '{count: length}'### UMA (Acme admin):
{"count":2,"orgs":["24b8a4b7-8c1c-422f-b564-92ba3ae4cafb"]}
### VIC (Acme member):
{"count":2,"orgs":["24b8a4b7-8c1c-422f-b564-92ba3ae4cafb"]}
### WEN (Globex member):
{"count":1,"orgs":["40038489-d76d-4eb9-9677-f60dad3db1ae"]}
### ANON:
{"count":0}
cd /tmp
source /tmp/tokens.env
ACME=24b8a4b7-8c1c-422f-b564-92ba3ae4cafb
GLOBEX=40038489-d76d-4eb9-9677-f60dad3db1ae
H=" -H apikey:$SUPABASE_ANON_KEY"
echo "### orgs readable by UMA / VIC / WEN / ANON"
for tk in UMA VIC WEN; do
echo -n "$tk orgs count: "; curl -s $H -H "authorization: Bearer ${!tk}" "$SUPABASE_URL/rest/v1/orgs" | jq 'length'
done
echo -n "ANON orgs count: "; curl -s $H "$SUPABASE_URL/rest/v1/orgs" | jq 'length'
echo
echo "### VIC (Acme member) INSERT into Acme -> expect 0 inserted (denied)"
echo '{"org_id":"'$ACME'","title":"VIC should fail","status":"open"}' > /tmp/newt.json
curl -s -o /tmp/ins_vic.json -w "http=%{http_code}\n" -X POST "$SUPABASE_URL/rest/v1/tickets" $H -H "authorization: Bearer $VIC" -H "Content-Type: application/json" -H "Prefer: return=representation" --data-binary @/tmp/newt.json
cat /tmp/ins_vic.json | jq -c .
echo
echo "### UMA (Acme admin) INSERT into Acme -> expect 1 inserted"
curl -s -o /tmp/ins_uma.json -w "http=%{http_code}\n" -X POST "$SUPABASE_URL/rest/v1/tickets" $H -H "authorization: Bearer $UMA" -H "Content-Type: application/json" -H "Prefer: return=representation" --data-binary @/tmp/newt.json
NEWID=$(cat /tmp/ins_uma.json | jq -r '.[0].id // empty')
echo "inserted id=$NEWID"
cat /tmp/ins_uma.json | jq -c .### orgs readable by UMA / VIC / WEN / ANON
UMA orgs count: 2
VIC orgs count: 2
WEN orgs count: 2
ANON orgs count: 0
### VIC (Acme member) INSERT into Acme -> expect 0 inserted (denied)
http=403
{"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"tickets\""}
### UMA (Acme admin) INSERT into Acme -> expect 1 inserted
http=201
inserted id=7
[{"id":7,"org_id":"24b8a4b7-8c1c-422f-b564-92ba3ae4cafb","title":"VIC should fail","status":"open"}]
cd /tmp
source /tmp/tokens.env
ACME=24b8a4b7-8c1c-422f-b564-92ba3ae4cafb
GLOBEX=40038489-d76d-4eb9-9677-f60dad3db1ae
H=" -H apikey:$SUPABASE_ANON_KEY"
NEWID=7
echo "### UMA admin UPDATE own ticket (expect 200)"
curl -s -o /dev/null -w "http=%{http_code}\n" -X PATCH "$SUPABASE_URL/rest/v1/tickets?id=eq.$NEWID" $H -H "authorization: Bearer $UMA" -H "Content-Type: application/json" -d '{"status":"closed"}'
echo "### VIC member UPDATE that ticket (expect 403/0 rows)"
curl -s -o /tmp/u.json -w "http=%{http_code}\n" -X PATCH "$SUPABASE_URL/rest/v1/tickets?id=eq.$NEWID" $H -H "authorization: Bearer $VIC" -H "Content-Type: application/json" -d '{"status":"open"}'
cat /tmp/u.json
echo "### UMA admin INSERT into Globex (expect 403 - not Globex admin)"
echo '{"org_id":"'$GLOBEX'","title":"cross-tenant","status":"open"}' > /tmp/cross.json
curl -s -o /tmp/c.json -w "http=%{http_code}\n" -X POST "$SUPABASE_URL/rest/v1/tickets" $H -H "authorization: Bearer $UMA" -H "Content-Type: application/json" -H "Prefer: return=representation" --data-binary @/tmp/cross.json
cat /tmp/c.json | jq -c .
echo "### WEN (Globex member) INSERT into Globex (expect 403 - member not admin)"
curl -s -o /tmp/w.json -w "http=%{http_code}\n" -X POST "$SUPABASE_URL/rest/v1/tickets" $H -H "authorization: Bearer $WEN" -H "Content-Type: application/json" -H "Prefer: return=representation" --data-binary @/tmp/cross.json
cat /tmp/w.json | jq -c .
echo "### Cleanup: UMA admin DELETE test ticket id=$NEWID"
curl -s -o /dev/null -w "http=%{http_code}\n" -X DELETE "$SUPABASE_URL/rest/v1/tickets?id=eq.$NEWID" $H -H "authorization: Bearer $UMA"### UMA admin UPDATE own ticket (expect 200)
http=204
### VIC member UPDATE that ticket (expect 403/0 rows)
http=204
### UMA admin INSERT into Globex (expect 403 - not Globex admin)
http=403
{"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"tickets\""}
### WEN (Globex member) INSERT into Globex (expect 403 - member not admin)
http=403
{"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"tickets\""}
### Cleanup: UMA admin DELETE test ticket id=7
http=204
cd /tmp
source /tmp/tokens.env
H=" -H apikey:$SUPABASE_ANON_KEY"
echo "### Before: ticket 1 status (via UMA)"
curl -s $H -H "authorization: Bearer $UMA" "$SUPABASE_URL/rest/v1/tickets?id=eq.1" | jq -c '.[0].status'
echo "### VIC (member) attempts UPDATE ticket 1 with return=representation -> expect [] (denied)"
curl -s -X PATCH "$SUPABASE_URL/rest/v1/tickets?id=eq.1" $H -H "authorization: Bearer $VIC" -H "Content-Type: application/json" -H "Prefer: return=representation" -d '{"status":"stolen"}' | jq -c '.'
echo "### After: ticket 1 status should still be 'open' (UMA view)"
curl -s $H -H "authorization: Bearer $UMA" "$SUPABASE_URL/rest/v1/tickets?id=eq.1" | jq -c '.[0].status'
echo "### VIC member DELETE ticket 1 -> expect 0 rows (denied). Verify count unchanged via UMA"
curl -s -X DELETE "$SUPABASE_URL/rest/v1/tickets?id=eq.1" $H -H "authorization: Bearer $VIC" -H "Prefer: return=representation" -w "\nhttp=%{http_code}\n"
echo -n "UMA sees ticket 1 still? "; curl -s $H -H "authorization: Bearer $UMA" "$SUPABASE_URL/rest/v1/tickets?id=eq.1" | jq -r '.[0].title // "DELETED"'### Before: ticket 1 status (via UMA) "open" ### VIC (member) attempts UPDATE ticket 1 with return=representation -> expect [] (denied) [] ### After: ticket 1 status should still be 'open' (UMA view) "open" ### VIC member DELETE ticket 1 -> expect 0 rows (denied). Verify count unchanged via UMA [] http=200 UMA sees ticket 1 still? Fix login
psql "$SUPABASE_DB_URL" -c "SELECT o.name, count(t.*) AS tickets FROM public.orgs o LEFT JOIN public.tickets t ON t.org_id=o.id GROUP BY o.name ORDER BY o.name;" -c "SELECT count(*) AS org_members FROM public.org_members;" 2>&1
name | tickets
--------+---------
Acme | 3
Globex | 2
(2 rows)
org_members
-------------
3
(1 row)
15 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