From 6dea55f32f7e9e8b8212512aadc0ff1c40032add Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:13:10 +0700 Subject: [PATCH 1/2] test(supabase): check that the sql functions honour row level security The install script declares security invoker on every function and enables row level security on the tables createIndex makes, but nothing failed if either stopped being true. A security definer function runs as its owner, and when that owner bypasses row level security, as a superuser or the table's owner does, anon reads every row through it. One test reads pg_proc and fails if any vecstore_ function is security definer, so a new function is covered without editing the test. A second test, run for anon and authenticated, calls query, fetch, upsert, and delete through the adapter against a secured table that has table grants and no policy. It expects empty reads, an unauthorized upsert, and a delete that removes nothing. The anon check before this read the table directly and never went through the vecstore_ functions. I saw both claims fail on purpose. Changing vecstore_fetch to security definer fails the catalog test and both role tests. Replacing the alter table statement that enables row level security fails both role tests and the two existing row level security tests. --- .../test/postgres/supabase.test.ts | 56 +++++++++++++++++-- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/vecstore-sdk/test/postgres/supabase.test.ts b/packages/vecstore-sdk/test/postgres/supabase.test.ts index 71b4e81..5cf8f0f 100644 --- a/packages/vecstore-sdk/test/postgres/supabase.test.ts +++ b/packages/vecstore-sdk/test/postgres/supabase.test.ts @@ -174,8 +174,11 @@ describe("a cosine vecstore_query that meets a zero vector on PGlite", () => { describe("supabase install script", () => { const db = usePostgres(installSupabaseSql); - const asAnon = async (fn: () => PromiseLike): Promise => { - await db().exec("set role anon"); + const asRole = async ( + role: string, + fn: () => PromiseLike + ): Promise => { + await db().exec(`set role ${role}`); try { return await fn(); } finally { @@ -215,19 +218,62 @@ describe("supabase install script", () => { await ensureRoles(db(), ["anon"]); await db().exec("grant select on secured_anon to anon"); - expect(await asAnon(() => countRows("secured_anon"))).toBe(0); + expect(await asRole("anon", () => countRows("secured_anon"))).toBe(0); await db().exec( "create policy secured_read on secured_anon for select to anon using (true)" ); - expect(await asAnon(() => countRows("secured_anon"))).toBe(1); + expect(await asRole("anon", () => countRows("secured_anon"))).toBe(1); }); + test("every vecstore function runs with the privileges of its caller", async () => { + const functions = await db().query<{ definer: boolean; name: string }>( + "select proname as name, prosecdef as definer from pg_proc where proname like 'vecstore\\_%'" + ); + expect(functions.rows.length).toBeGreaterThan(0); + expect(functions.rows.filter((row) => row.definer)).toStrictEqual([]); + }); + + test.each(["anon", "authenticated"])( + "%s reads and changes nothing through the adapter until a policy exists", + async (role) => { + const name = `secured_${role}_verbs`; + await createSecured(name); + const index = createSupabaseStore({ client: rpcClient(db()) }).index( + name + ); + await index.upsert([{ id: "a", vector: [0, 0, 1] }]); + + await ensureRoles(db(), [role]); + await db().exec( + `grant select, insert, update, delete on ${name} to ${role}` + ); + + const queried = await asRole(role, () => + index.query({ topK: 1, vector: [0, 0, 1] }) + ); + const fetched = await asRole(role, () => index.fetch(["a"])); + const upserted = await asRole(role, () => + index.upsert([{ id: "b", vector: [1, 0, 0] }]) + ); + const deleted = await asRole(role, () => index.delete({ ids: ["a"] })); + + expect(queried.ok && queried.value).toStrictEqual([]); + expect(fetched.ok && fetched.value).toStrictEqual([]); + expect(upserted.ok || upserted.error.kind).toBe("unauthorized"); + expect(deleted.ok).toBe(true); + const remaining = await db().query<{ id: string }>( + `select id from ${name}` + ); + expect(remaining.rows.map((row) => row.id)).toStrictEqual(["a"]); + } + ); + test("anon cannot call the ddl functions once the supabase roles exist", async () => { await ensureRoles(db(), ["anon", "authenticated", "service_role"]); await installSupabaseSql(db()); - const response = await asAnon(() => + const response = await asRole("anon", () => rpcClient(db()).rpc("vecstore_create_index", { dimension: 3, index_name: "x", From a9095191ce4fd4ec1d738d051fe2d024b1336e6b Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:13:10 +0700 Subject: [PATCH 2/2] chore(sql): drop the header that repeats the supabase docs The install command and the note that the script runs once per project are in the Supabase docs page and the README. The security model, that every function runs as the caller and that created tables have row level security with no policy, is in the "Which key to use" section of the same page. The previous commit made those two security sentences tests, so the comment no longer carries anything the docs and the suite do not. --- packages/vecstore-sdk/sql/supabase.sql | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/vecstore-sdk/sql/supabase.sql b/packages/vecstore-sdk/sql/supabase.sql index 8dbd9e6..84ab804 100644 --- a/packages/vecstore-sdk/sql/supabase.sql +++ b/packages/vecstore-sdk/sql/supabase.sql @@ -1,11 +1,3 @@ --- vecstore-sdk: server side for the Supabase adapter. --- Run once per project, with a role that can create functions. --- psql "$SUPABASE_DB_URL" -f node_modules/vecstore-sdk/sql/supabase.sql --- Every function runs with the privileges of the caller. Tables that --- vecstore_create_index creates have row level security enabled and no --- policies, so the anon and authenticated roles read and write nothing until --- you add one. The service role bypasses row level security. - create extension if not exists vector; create or replace function vecstore_table(index_name text, index_schema text)