Regresi akses pada Row Level Security Supabase biasanya terjadi bukan karena RLS tidak aktif, tetapi karena perubahan policy, grant, migration, atau query baru tidak diuji sebagai role yang benar. Strategi testing RLS Supabase yang aman harus memverifikasi akses dari sudut pandang anon, authenticated, dan jalur admin atau service_role, bukan hanya memastikan query berhasil sebagai user database superuser.
Workflow yang praktis adalah: petakan skenario akses per role, siapkan database lokal atau CI dengan seed minimal, jalankan test positif dan negatif untuk operasi SELECT, INSERT, UPDATE, dan DELETE, lalu jadikan perubahan policy sebagai bagian wajib dari review migration. Dengan cara ini, perubahan kecil seperti menambahkan policy USING (true) atau grant yang terlalu luas lebih mudah tertangkap sebelum masuk production.
Mengapa RLS perlu dites secara eksplisit
Supabase menggunakan PostgreSQL Row Level Security untuk membatasi baris yang bisa diakses oleh user. Policy biasanya bergantung pada klaim JWT, misalnya melalui auth.uid() atau role request. Masalahnya, banyak test backend berjalan memakai koneksi privileged, owner table, atau service key. Jalur tersebut dapat melewati pembatasan yang dialami client biasa, sehingga test tetap hijau walaupun policy untuk user sebenarnya bocor.
Testing RLS harus menjawab pertanyaan konkret berikut:
- Apakah user tenant A hanya bisa membaca data tenant A?
- Apakah user biasa gagal mengubah atau menghapus data yang bukan miliknya?
- Apakah
anonbenar-benar tidak mendapat akses ke tabel privat? - Apakah operasi admin memakai jalur yang disengaja, bukan karena policy terlalu longgar?
- Apakah migration baru mengubah grant, view, function, atau policy yang berdampak pada isolasi tenant?
Prinsip penting: test RLS harus berjalan sebagai role yang sama dengan role runtime aplikasi. Jangan menyimpulkan keamanan policy dari test yang berjalan sebagai
postgresatau memakai service key.
Petakan skenario akses per role sebelum menulis test
Sebelum membuat test SQL atau integration test, buat matriks akses. Matriks ini membantu reviewer melihat perilaku yang diharapkan dan membuat test negatif tidak terlupakan.
Contoh matriks akses multi-tenant
Misalkan aplikasi memiliki tabel projects dengan kolom tenant_id dan tabel memberships yang menghubungkan user ke tenant.
- anon: tidak boleh membaca, membuat, mengubah, atau menghapus project.
- authenticated member: boleh membaca project di tenant tempat ia menjadi member.
- authenticated owner/member biasa: boleh membuat project hanya di tenant miliknya dan dengan
owner_iddirinya sendiri. - authenticated non-member: tidak boleh membaca atau memodifikasi project tenant lain.
- tenant admin: boleh menghapus project di tenant yang ia kelola, tetapi tetap tidak boleh menyentuh tenant lain.
- service_role: digunakan hanya untuk proses backend tepercaya seperti job internal, migration, atau admin task; bukan untuk memvalidasi akses client.
Dari matriks tersebut, turunkan test positif dan negatif. Jangan hanya menguji bahwa user A bisa membaca data tenant A. Uji juga bahwa user A tidak bisa membaca, membuat, memperbarui, atau menghapus data tenant B.
Contoh schema dan policy yang bisa diuji
Contoh berikut memakai schema sederhana. Ini bukan satu-satunya desain yang benar, tetapi cukup realistis untuk menunjukkan pola pengujian RLS Supabase.
create schema if not exists app;
create table app.memberships (
user_id uuid not null,
tenant_id uuid not null,
role text not null check (role in ('member', 'admin')),
primary key (user_id, tenant_id)
);
create table app.projects (
id uuid primary key,
tenant_id uuid not null,
owner_id uuid not null,
name text not null
);
alter table app.memberships enable row level security;
alter table app.projects enable row level security;
grant usage on schema app to anon, authenticated;
grant select on app.memberships to authenticated;
grant select, insert, update, delete on app.projects to authenticated;
create policy membership_self_select
on app.memberships
for select
to authenticated
using (user_id = auth.uid());
create policy project_select_member
on app.projects
for select
to authenticated
using (
exists (
select 1
from app.memberships m
where m.tenant_id = projects.tenant_id
and m.user_id = auth.uid()
)
);
create policy project_insert_member
on app.projects
for insert
to authenticated
with check (
owner_id = auth.uid()
and exists (
select 1
from app.memberships m
where m.tenant_id = projects.tenant_id
and m.user_id = auth.uid()
)
);
create policy project_update_owner
on app.projects
for update
to authenticated
using (owner_id = auth.uid())
with check (
owner_id = auth.uid()
and exists (
select 1
from app.memberships m
where m.tenant_id = projects.tenant_id
and m.user_id = auth.uid()
)
);
create policy project_delete_admin
on app.projects
for delete
to authenticated
using (
exists (
select 1
from app.memberships m
where m.tenant_id = projects.tenant_id
and m.user_id = auth.uid()
and m.role = 'admin'
)
);Perhatikan pemisahan USING dan WITH CHECK. Untuk UPDATE, USING menentukan baris lama yang boleh disentuh, sedangkan WITH CHECK menentukan bentuk baris baru yang boleh disimpan. Tanpa WITH CHECK yang tepat, user bisa saja mengubah kolom sensitif seperti tenant_id atau owner_id jika query aplikasinya lalai.
Menyiapkan database lokal dan CI dengan seed minimal
Seed untuk test RLS sebaiknya kecil, deterministik, dan mewakili batas akses. Hindari seed besar dari staging karena sulit dipahami dan membuat hasil test bergantung pada data historis.
Minimal, siapkan data berikut:
- Satu tenant A dan satu tenant B.
- User A sebagai member tenant A.
- User B sebagai member tenant B.
- User admin A sebagai admin tenant A.
- Satu project di tenant A dan satu project di tenant B.
Seed deterministik membuat test mudah dibaca dan tidak perlu lookup tambahan:
delete from app.projects;
delete from app.memberships;
insert into app.memberships (user_id, tenant_id, role) values
('00000000-0000-0000-0000-0000000000a1', '10000000-0000-0000-0000-0000000000a1', 'member'),
('00000000-0000-0000-0000-0000000000b1', '10000000-0000-0000-0000-0000000000b1', 'member'),
('00000000-0000-0000-0000-0000000000ad', '10000000-0000-0000-0000-0000000000a1', 'admin');
insert into app.projects (id, tenant_id, owner_id, name) values
('20000000-0000-0000-0000-0000000000a1', '10000000-0000-0000-0000-0000000000a1', '00000000-0000-0000-0000-0000000000a1', 'Project Tenant A'),
('20000000-0000-0000-0000-0000000000b1', '10000000-0000-0000-0000-0000000000b1', '00000000-0000-0000-0000-0000000000b1', 'Project Tenant B');Di lokal, jalankan test setelah migration diterapkan. Di CI, gunakan database ephemeral atau database test yang selalu di-reset. Yang penting, test harus berjalan terhadap schema hasil migration terbaru, bukan schema manual yang berbeda dari production.
Struktur test SQL untuk SELECT, INSERT, UPDATE, dan DELETE
Test SQL cocok untuk memvalidasi policy secara langsung karena bisa mengubah role session dan klaim JWT yang dibaca oleh helper Supabase seperti auth.uid(). Pola umumnya adalah menjalankan query dalam transaksi, memakai set local role authenticated, mengisi klaim user, lalu melakukan assertion.
Contoh berikut menguji beberapa skenario utama untuk user A. Simpan sebagai misalnya tests/rls/projects.sql.
\set ON_ERROR_STOP on
-- Seed dijalankan sebagai role privileged di database test.
reset role;
delete from app.projects;
delete from app.memberships;
insert into app.memberships (user_id, tenant_id, role) values
('00000000-0000-0000-0000-0000000000a1', '10000000-0000-0000-0000-0000000000a1', 'member'),
('00000000-0000-0000-0000-0000000000b1', '10000000-0000-0000-0000-0000000000b1', 'member'),
('00000000-0000-0000-0000-0000000000ad', '10000000-0000-0000-0000-0000000000a1', 'admin');
insert into app.projects (id, tenant_id, owner_id, name) values
('20000000-0000-0000-0000-0000000000a1', '10000000-0000-0000-0000-0000000000a1', '00000000-0000-0000-0000-0000000000a1', 'Project Tenant A'),
('20000000-0000-0000-0000-0000000000b1', '10000000-0000-0000-0000-0000000000b1', '00000000-0000-0000-0000-0000000000b1', 'Project Tenant B');
-- User A: hanya boleh melihat project tenant A.
begin;
set local role authenticated;
select set_config('request.jwt.claim.sub', '00000000-0000-0000-0000-0000000000a1', true);
DO $$
DECLARE
visible_count int;
BEGIN
select count(*) into visible_count from app.projects;
if visible_count != 1 then
raise exception 'expected user A to see 1 project, got %', visible_count;
end if;
END $$;
-- INSERT positif: user A membuat project di tenant A atas nama dirinya.
insert into app.projects (id, tenant_id, owner_id, name) values
('20000000-0000-0000-0000-0000000000a2', '10000000-0000-0000-0000-0000000000a1', '00000000-0000-0000-0000-0000000000a1', 'New Project A');
-- INSERT negatif: user A mencoba membuat project di tenant B.
DO $$
DECLARE
got_error boolean := false;
BEGIN
begin
insert into app.projects (id, tenant_id, owner_id, name) values
('20000000-0000-0000-0000-0000000000b2', '10000000-0000-0000-0000-0000000000b1', '00000000-0000-0000-0000-0000000000a1', 'Illegal Cross Tenant Insert');
exception when others then
got_error := true;
end;
if not got_error then
raise exception 'expected cross-tenant insert to fail';
end if;
END $$;
-- UPDATE negatif: user A tidak boleh memindahkan project miliknya ke tenant B.
DO $$
DECLARE
got_error boolean := false;
BEGIN
begin
update app.projects
set tenant_id = '10000000-0000-0000-0000-0000000000b1'
where id = '20000000-0000-0000-0000-0000000000a1';
exception when others then
got_error := true;
end;
if not got_error then
raise exception 'expected tenant change update to fail';
end if;
END $$;
-- DELETE negatif: member biasa bukan admin, sehingga tidak boleh menghapus project.
DO $$
DECLARE
affected int;
BEGIN
delete from app.projects
where id = '20000000-0000-0000-0000-0000000000a1';
get diagnostics affected = row_count;
if affected != 0 then
raise exception 'expected member delete to affect 0 rows, got %', affected;
end if;
END $$;
rollback;Beberapa operasi yang ditolak RLS akan menghasilkan error, terutama INSERT atau UPDATE yang melanggar WITH CHECK. Operasi lain bisa menghasilkan nol baris karena row dianggap tidak terlihat oleh policy USING. Keduanya valid sebagai hasil negatif, tetapi test harus eksplisit: apakah yang diharapkan error atau row_count = 0.
Test untuk anon
Jika tabel privat tidak diberi grant ke anon, query sebagai anon seharusnya gagal karena permission denied. Jika Anda memang memberi grant publik pada tabel tertentu, policy harus memastikan hasilnya terbatas. Jangan campur dua model ini tanpa test yang jelas.
begin;
set local role anon;
DO $$
DECLARE
got_error boolean := false;
BEGIN
begin
perform count(*) from app.projects;
exception when others then
got_error := true;
end;
if not got_error then
raise exception 'expected anon access to projects to fail';
end if;
END $$;
rollback;Test untuk admin tenant
Test positif juga penting. Jika admin tenant A memang boleh menghapus project tenant A, validasi jalur tersebut. Lalu tambahkan test negatif bahwa admin tenant A tidak bisa menghapus tenant B.
begin;
set local role authenticated;
select set_config('request.jwt.claim.sub', '00000000-0000-0000-0000-0000000000ad', true);
DO $$
DECLARE
affected int;
BEGIN
delete from app.projects
where id = '20000000-0000-0000-0000-0000000000a1';
get diagnostics affected = row_count;
if affected != 1 then
raise exception 'expected tenant admin to delete 1 project, got %', affected;
end if;
delete from app.projects
where id = '20000000-0000-0000-0000-0000000000b1';
get diagnostics affected = row_count;
if affected != 0 then
raise exception 'expected tenant admin A not to delete tenant B project';
end if;
END $$;
rollback;Membedakan anon, authenticated, dan service role
Dalam Supabase, perbedaan role ini harus tercermin dalam test dan arsitektur aplikasi.
- anon: role untuk request tanpa login atau public API. Jangan beri grant ke tabel privat kecuali ada alasan dan policy yang diuji.
- authenticated: role untuk user login. Inilah target utama test RLS berbasis tenant, ownership, membership, dan permission aplikasi.
- service_role: key atau role backend tepercaya yang dapat melewati pembatasan RLS. Jangan dipakai di frontend dan jangan dipakai untuk membuktikan bahwa policy user aman.
Kesalahan umum adalah memakai service key dalam integration test lalu menganggap hasilnya mewakili user login. Test seperti itu hanya membuktikan jalur admin bekerja. Untuk user-facing API, gunakan JWT user biasa atau simulasi klaim seperti pada test SQL.
Jika backend Anda memiliki endpoint admin yang memakai service key, uji dua hal secara terpisah:
- Authorization di layer aplikasi: hanya actor admin internal yang boleh memanggil endpoint tersebut.
- Efek database: operasi admin memang menghasilkan perubahan yang diharapkan.
Jangan berharap RLS melindungi query yang sengaja dijalankan melalui jalur service role. Proteksinya harus berada pada boundary backend, secret management, dan authorization aplikasi.
Checklist pipeline CI untuk testing RLS Supabase
Pipeline CI tidak harus rumit. Yang penting adalah urutan dan isolation-nya konsisten.
- Start database test yang bersih, misalnya melalui Supabase lokal, container PostgreSQL yang kompatibel dengan schema Anda, atau database ephemeral.
- Terapkan migration dari awal. Jangan memakai schema manual yang tidak berasal dari migration repository.
- Jalankan seed minimal khusus test RLS.
- Jalankan test SQL dengan mode fail fast, misalnya
psqldenganON_ERROR_STOP. - Jalankan integration test API jika ada endpoint yang membungkus query penting, terutama RPC, view, atau endpoint backend yang memakai service key.
- Pastikan secret aman. Service key hanya boleh tersedia di job yang benar-benar membutuhkannya, bukan di test frontend atau preview client.
- Simpan log database saat gagal agar error policy, permission denied, atau row count mismatch mudah dianalisis.
Contoh command generik di CI:
supabase db reset
psql $DATABASE_URL -v ON_ERROR_STOP=1 -f tests/rls/projects.sqlJika Anda tidak memakai Supabase CLI di CI, prinsipnya tetap sama: database harus bersih, migration diterapkan, seed deterministik dijalankan, lalu test RLS dieksekusi sebagai role yang benar.
Pola review migration agar policy tidak membuka data lintas tenant
Review migration adalah lapisan pertahanan kedua setelah test. Setiap perubahan pada RLS sebaiknya dianggap perubahan security-sensitive.
Hal yang wajib dicek saat review
- Apakah setiap tabel yang menyimpan data tenant memiliki
enable row level security? - Apakah ada policy baru dengan kondisi terlalu luas seperti
USING (true)untukauthenticated? - Apakah
WITH CHECKsudah ada untukINSERTdanUPDATE? - Apakah perubahan grant membuka akses ke
anonatauauthenticatedtanpa policy yang sesuai? - Apakah kolom tenant, owner, atau organization bisa diubah oleh user biasa?
- Apakah view, RPC, trigger, atau function
SECURITY DEFINERmembaca tabel privat dengan cara yang melewati RLS? - Apakah policy membership sendiri berisiko rekursif atau membuka daftar member tenant lain?
- Apakah test negatif lintas tenant ikut diperbarui ketika ada role atau permission baru?
Khusus untuk view dan function, jangan hanya melihat query aplikasinya. Periksa context eksekusinya. Function dengan hak definer atau view yang dimiliki role privileged dapat menghasilkan perilaku berbeda dari query langsung sebagai user. Jika fungsi tersebut memang harus privileged, pindahkan authorization ke dalam function dan buat test khusus untuk fungsi itu.
Anti-pattern yang sering meloloskan regresi
- Test hanya positif: hanya menguji user bisa membaca data sendiri, tanpa mencoba data tenant lain.
- Semua test memakai service key: cepat, tetapi tidak mewakili akses client.
- Seed tidak punya tenant pembanding: tanpa tenant B, kebocoran lintas tenant tidak bisa terdeteksi.
- Policy SELECT dianggap cukup untuk semua operasi:
INSERTdanUPDATEmembutuhkanWITH CHECK. - Mengandalkan frontend untuk menyaring tenant: RLS harus tetap benar walaupun client mengirim query yang tidak diharapkan.
Debugging saat test RLS gagal
Jika test gagal, mulai dari membedakan tiga jenis masalah: permission database, policy RLS, atau klaim JWT.
- Jika error permission denied, cek
grantpada schema, table, sequence, atau function. - Jika hasil nol baris padahal seharusnya ada, cek ekspresi
USINGdan nilaiauth.uid()pada session test. - Jika
INSERTatauUPDATEgagal, cekWITH CHECKterhadap nilai baris baru. - Jika test lolos sebagai SQL langsung tetapi gagal lewat API, cek JWT, header Authorization, endpoint, view, atau RPC yang digunakan.
- Jika hanya gagal di CI, pastikan migration dan seed dijalankan dalam urutan yang sama dengan lokal.
Untuk investigasi, buat query kecil yang memeriksa identitas session dan data membership yang terlihat oleh user test. Jangan lupa menghapus query debugging dari test final jika mengandung data sensitif.
Kesimpulan
Testing RLS Supabase yang efektif harus memperlakukan policy sebagai kontrak keamanan, bukan konfigurasi tambahan. Mulailah dari matriks akses per role, gunakan seed minimal dengan dua tenant atau lebih, uji semua operasi CRUD, dan selalu sertakan test negatif lintas tenant.
Di CI, jalankan test terhadap database bersih hasil migration terbaru dan hindari service key untuk skenario user biasa. Saat review migration, fokus pada perubahan policy, grant, view, RPC, dan function privileged. Kombinasi test otomatis dan review yang disiplin akan jauh mengurangi risiko regresi akses data lolos ke production.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!