iAct

Act on Ai

ورود کاربران و RLS در Supabase؛ چطور دادهٔ هر کاربر را جدا نگه داریم


طراحی دیتابیس با Supabase و SQL؛ از فرم ساده تا دادهٔ قابل‌اعتماد
اگر دو نفر با یک اپ ثبت اطلاعات مزرعه کار کنند، هر دو باید داده‌هایشان را در یک سیستم مشترک ثبت کنند؛ اما «مشترک بودن دیتابیس» نباید به معنی «قابل‌دیدن بودن همهٔ رکوردها برای همه» باشد. در این آموزش، مسیر تبدیل یک اپ تک‌کاربره به اپی با ورود کاربران را مرور می‌کنیم و بعد، با یک مثال تکمیلی آموزشی، جدولی در Supabase می‌سازیم که هر کاربر فقط بتواند رکوردهای خودش را بخواند، ثبت کند، تغییر دهد یا حذف کند. در پایان هم این محدودیت را واقعاً با دو حساب جدا امتحان می‌کنی.

۱. اول مشکل را دقیق تعریف کن: دیتابیس مشترک، دسترسی جدا

در نمونهٔ «آوای مزرعه»، مسئله فقط ساخت صفحهٔ ورود نبود. قرار بود چند نفر اطلاعات ثبت کنند و بعد بتوان دربارهٔ دسترسی اپراتور و مدیر تصمیم گرفت. اگر اطلاعات صرفاً در محیط محلی هر کاربر بماند، جمع‌کردن و بررسی متمرکز آن دشوار می‌شود. پس به جایی مشترک برای نگهداری داده نیاز داریم.

اما اینجا دو سؤال متفاوت داریم: «اطلاعات کجا ذخیره می‌شود؟» و «چه کسی اجازه دارد کدام اطلاعات را بخواند یا تغییر دهد؟» Supabase می‌تواند برای بخش اول، دیتابیس PostgreSQL و برای ورود کاربران، Auth فراهم کند. پاسخ سؤال دوم را باید با قواعد دسترسی دیتابیس، از جمله Row Level Security یا RLS، طراحی و آزمایش کنی. داشتن صفحهٔ ورود به‌تنهایی جواب آن نیست.

خروجی این مرحله یک جملهٔ روشن برای پروژه است: «رکوردها در دیتابیس مشترک‌اند، ولی دسترسی مستقیم هر کاربر عادی فقط به رکوردهای متعلق به خودش محدود است.» هنوز دربارهٔ دسترسی مدیر تصمیم نگرفته‌ایم؛ آن را نباید ناخواسته داخل همین قانون ساده جا بدهیم.

۲. مرز فرانت‌اند، Auth و RLS را بشناس

فرانت‌اند همان رابطی است که کاربر در مرورگر یا PWA می‌بیند. می‌تواند دکمهٔ ثبت را نمایش بدهد، فرم را پر کند و درخواست ارسال کند؛ ولی مرجع قابل‌اعتماد برای تعیین مالکیت داده نیست. هر کسی می‌تواند درخواست شبکه را بیرون از رابط ظاهری اپ هم بسازد. بنابراین پنهان‌کردن دکمهٔ حذف یا صفحهٔ مدیریت، مجوز دسترسی ایجاد نمی‌کند.

Supabase Auth هویت کاربر واردشده را مشخص می‌کند. در سیاست‌های RLS، تابع auth.uid() شناسهٔ همان کاربر را در زمینهٔ درخواست احرازشده برمی‌گرداند. سپس PostgreSQL برای هر ردیف بررسی می‌کند آیا عملیات خواسته‌شده مجاز است یا نه. برای مثال، اگر ستون owner_id رکورد با auth.uid() برابر باشد، کاربر مالک آن رکورد است.

این تفکیک در طراحی کمک می‌کند: رابط خوب، تجربهٔ استفاده را ساده می‌کند؛ Auth پاسخ می‌دهد «چه کسی وارد شده؟»؛ و RLS پاسخ می‌دهد «این فرد با این ردیف چه کاری می‌تواند بکند؟» برای آشنایی بیشتر با مفاهیم، راهنمای رسمی Supabase Auth و Supabase RLS مرجع مناسبی هستند.

۳. قبل از ساخت جدول، محدودهٔ داده را کوچک نگه دار

در مسیر پروژه، به‌جای انتقال هم‌زمان تمام قابلیت‌ها، تصمیم گرفته شد داده‌های ساختاریافته، مثل اطلاعات مزرعه، کاربر و رکورد ثبت‌شده، محور اتصال به دیتابیس باشند. صف آفلاین و فایل صوتی مسئله‌های جداگانه‌ای هستند. صف برای نگهداری موقت کارِ انجام‌نشده روی دستگاه کاربرد دارد؛ ذخیرهٔ فایل صوتی نیز با ذخیرهٔ متن و فیلدهای فرم در جدول یکی نیست. RLS از داده‌های داخل مرورگر یا دستگاه محافظت نمی‌کند. اگر چند نفر از یک دستگاه استفاده می‌کنند، صف، صوت و داده‌های محلی را به حساب مربوط جدا کن و نگذار حساب بعدی به آن‌ها دسترسی پیدا کند؛ صفِ ارسال‌نشده را هم بی‌هشدار پاک نکن.

برای تمرین امنیت، حتی از این هم کوچک‌تر شروع می‌کنیم: یک جدول رکورد با نام قطعهٔ زمین و یادداشت. این جدول قرار نیست شِمای کامل «آوای مزرعه» را بازسازی کند. مزیتش این است که می‌توانی رابطهٔ مالک و رکورد را بدون درگیرشدن با نقش مدیر، مزرعه‌های مشترک، صوت و همگام‌سازی آفلاین بفهمی. وقتی این قانون پایه درست کار کرد، نوبت طراحی روابط و مجوزهای پیچیده‌تر است.

نتیجهٔ مورد انتظار در این مرحله، یک تصمیم طراحی قابل‌نوشتن است: هر رکورد یک owner_id دارد که به شناسهٔ کاربر در Auth اشاره می‌کند؛ کاربر برای کار با رکوردها باید وارد شده باشد. «اپراتور» یا «مدیر» بودن را فعلاً با تغییر دستی یک برچسب در پروفایل، مجوز دیتابیس فرض نمی‌کنیم.

۴. پروژه و دو حساب آزمایشی را آماده کن

برای اجرای تمرین، به یک پروژهٔ Supabase و دسترسی به SQL Editor آن نیاز داری. آزمونِ درخواست‌های کاربران عادی در بخش ۸ همچنین فرض می‌کند Data API پروژه فعال است و شِمای public را در دسترس می‌گذارد. نشانی پروژه و کلید عمومیِ publishable، یا کلید قدیمیِ anon، را از اطلاعات اتصال/API همان پروژه بردار؛ برای این آزمون به کلید secret یا service_role نیاز نداری. در بخش Auth پروژه، روشی برای ورود دو حساب آزمایشی جدا آماده کن؛ مثلاً دو حساب ایمیل و گذرواژه. بسته به تنظیمات تأیید ایمیل پروژه، ممکن است لازم باشد ثبت‌نام هر حساب را پیش از آزمون کامل کنی. از ایمیل و گذرواژهٔ واقعی دیگران برای تمرین استفاده نکن.

یک نکتهٔ امنیتی را همین ابتدا تثبیت کن: کلیدی که با سطح دسترسی service_role یا به‌صورت secret برای کارهای مدیریتی در نظر گرفته شده، نباید داخل کد مرورگر، فایل‌های منتشرشدهٔ PWA، مخزن عمومی یا متن گفت‌وگو با ابزار کمکی قرار بگیرد. برای دسترسی معمول کاربر، درخواست باید با نشست احرازشدهٔ همان کاربر انجام شود. service_role برای کارهای مورداعتماد سمت سرور است و می‌تواند RLS را دور بزند؛ پس ابزار آزمون محدودیت کاربران نیست. اگر کلید secret یا service_role را قبلاً در گفت‌وگو، خروجی منتشرشده یا مخزن افشا کرده‌ای، آن را در پروژه لغو یا تعویض کن و محل‌های انتشارش را نیز بررسی کن.

هنوز لازم نیست اپ اصلی را تغییر بدهی. خروجی این مرحله دو حساب قابل‌ورود و یک پروژهٔ آماده برای اجرای SQL است. اگر ورود یکی از حساب‌ها کامل نشده، قبل از بررسی سیاست‌ها همان مشکل Auth را حل کن؛ وگرنه ردشدن درخواست می‌تواند علت دیگری داشته باشد.

۵. مثال تکمیلی آموزشی: جدولِ دارای مالک بساز

مثال تکمیلی آموزشی: SQL زیر یک نمونهٔ مستقل و کوچک است، نه کپی شِما یا سیاست‌های ساخته‌شده در پروژهٔ ضبط‌شده. آن را در SQL Editor همان پروژهٔ آزمایشی Supabase اجرا کن. این مثال فرض می‌کند هنوز جدولی به نام public.field_notes نساخته‌ای؛ اجرای دوبارهٔ دستور create table بدون تغییر، خطا می‌دهد.

create table public.field_notes (
  id uuid primary key default gen_random_uuid(),
  owner_id uuid not null default auth.uid()
    references auth.users(id) on delete cascade,
  field_name text not null,
  note text not null,
  created_at timestamptz not null default now()
);

alter table public.field_notes enable row level security;

revoke all on public.field_notes from anon;
grant select, insert, update, delete
  on public.field_notes to authenticated;

id شناسهٔ خود رکورد است و owner_id شناسهٔ مالک آن. مقدار پیش‌فرض auth.uid() کمک می‌کند هنگام ثبت از یک نشست معتبر، مالک به‌طور خودکار همان کاربر باشد؛ اما مقدار پیش‌فرض، جای سیاست امنیتی را نمی‌گیرد. کلاینت ممکن است مقدار دیگری برای ستون بفرستد، پس در مرحلهٔ بعد درج و تغییر را نیز کنترل می‌کنیم. on delete cascade یعنی اگر کاربر متناظر در Auth حذف شود، این یادداشت‌های وابسته هم حذف می‌شوند؛ اگر چنین رفتاری برای محصولت مناسب نیست، باید پیش از استفادهٔ واقعی تصمیم دیگری بگیری.

دستور revoke دسترسی مستقیم نقش مهمان (anon) را از این جدول برمی‌دارد و grant عملیات لازم را به نقش کاربران واردشده می‌دهد. RLS سپس درون همین عملیات، ردیف مجاز را محدود می‌کند. نتیجهٔ مورد انتظار پس از اجرای این بلوک، جدولی خالی و RLS فعال است؛ هنوز هیچ رکوردی و هیچ سیاست مالکیتی برای استفادهٔ عادی ساخته نشده است.

۶. چهار عملیات را با سیاست مالکیت محدود کن

حالا در همان SQL Editor سیاست‌های زیر را اجرا کن. هر سیاست هم به نقش authenticated محدود است و هم وجود شناسهٔ کاربر را صریح بررسی می‌کند. USING تعیین می‌کند کدام ردیف موجود برای خواندن، تغییر یا حذف در دسترس است؛ WITH CHECK تعیین می‌کند ردیف تازه یا نسخهٔ تازهٔ ردیف بعد از درج و تغییر، مجاز هست یا نه.

create policy "owner can select notes"
on public.field_notes for select to authenticated
using (
  (select auth.uid()) is not null
  and owner_id = (select auth.uid())
);

create policy "owner can insert notes"
on public.field_notes for insert to authenticated
with check (
  (select auth.uid()) is not null
  and owner_id = (select auth.uid())
);

create policy "owner can update notes"
on public.field_notes for update to authenticated
using (
  (select auth.uid()) is not null
  and owner_id = (select auth.uid())
)
with check (
  (select auth.uid()) is not null
  and owner_id = (select auth.uid())
);

create policy "owner can delete notes"
on public.field_notes for delete to authenticated
using (
  (select auth.uid()) is not null
  and owner_id = (select auth.uid())
);

دلیل داشتن دو شرط در UPDATE مهم است: کاربر باید مالک ردیف قبلی باشد و ردیف پس از تغییر نیز همچنان متعلق به خودش بماند. در INSERT هم اگر کسی با دستکاری درخواست، owner_id کاربر دیگری را بفرستد، شرط درج باید آن را رد کند. سیاست DELETE فقط اجازهٔ حذف ردیف‌های خود فرد را می‌دهد.

پس از اجرای موفق این بلوک، انتظار داریم چهار عمل معمول فقط روی دادهٔ خودِ کاربر ممکن باشند. این انتظار را هنوز «اثبات‌شده» حساب نکن؛ SQL Editor معمولاً با سطح دسترسی مدیریتی اجرا می‌شود و دیدن همهٔ ردیف‌ها در آن، نشان‌دهندهٔ دسترسی یک کاربر عادی نیست. آزمون واقعی باید با نشست‌های عادی انجام شود.

۷. اپ را وصل کن، بدون اینکه کلید مدیریتی را منتشر کنی

در پروژهٔ اصلی، پس از مشخص‌شدن ورود ایمیل و گذرواژه، شِما در SQL Editor اعمال شد و اتصال اپ به Supabase و انتشار نسخهٔ قابل‌آزمایش دنبال شد. در تمرین تو نیز ترتیب منطقی همین است: اول Auth، بعد جدول و سیاست، سپس درخواست‌های اپ. اگر از یک دستیار کدنویسی کمک می‌گیری، تغییرات پیشنهادی‌اش را پیش از انتشار مرور کن؛ ساختن یک رابط تازه به قیمت از دست رفتن قابلیت‌های قبلی، پیشرفت محسوب نمی‌شود.

بازنویسی آموزشی پرامپت: متن زیر پیشنهاد تازه‌ای برای دادن به دستیار کدنویسی است، نه نقل‌قول لفظی از فرایند ضبط‌شده:

در اپ موجود، ورود کاربر را به Supabase Auth وصل کن و عملیات
خواندن، ثبت، ویرایش و حذف public.field_notes را فقط با نشست
احرازشدهٔ همان کاربر انجام بده. owner_id را از ورودی آزاد فرم
نگیر؛ سیاست‌های RLS دیتابیس باید مرجع نهایی باشند.
هیچ service_role یا کلید secret را به کد مرورگر اضافه نکن.
پیش از تغییر، جریان فعلی فرم و صف آفلاین را شناسایی کن و
بگو کدام فایل‌ها تغییر می‌کنند و چطور دو حساب را آزمایش می‌کنی.

این متن دستور به ابزار کمکی است، نه فرمان ترمینال. مسیر دقیق فایل‌های پروژه و روش اجرای اپ به ساختار همان پروژه بستگی دارد؛ بدون دیدن آن، نباید نام فایل یا دستور اجرای ساختگی پیشنهاد کرد. نتیجهٔ مورد انتظار از اتصال، این است که درخواست‌های داده با نشست همان کاربر واردشده فرستاده شوند و پس از خروج، درخواست‌های بعدیِ آن کلاینت دیگر با هویت قبلی ارسال نشوند. خروج، داده‌های محلی را خودبه‌خود پاک نمی‌کند و توکن دسترسیِ صادرشده نیز ممکن است تا زمان انقضایش معتبر بماند.

۸. با دو کاربر، هر چهار سیاست را امتحان کن

دو نشست جدا باز کن؛ مثلاً یکی در پنجرهٔ معمولی مرورگر و دیگری در پنجرهٔ خصوصی. با حساب «الف» وارد شو و یادداشتی با field_name برابر «قطعهٔ شمالی» ثبت کن. با حساب «ب» وارد شو و یادداشت «قطعهٔ جنوبی» را بساز. حالا فهرست را در هر دو نشست تازه‌سازی کن: الف باید فقط رکورد خودش را ببیند و ب نیز فقط رکورد خودش را.

برای آزمون سخت‌تر، باید درخواست را با نشست عادی ب، نه SQL Editor و نه service_role بفرستی. مثال تکمیلی آموزشی: راه مستقل از رابط اپ، اجرای اسکریپت زیر در یک پوشهٔ آزمایشی با Node.js پشتیبانی‌کنندهٔ --env-file است. npm install @supabase/supabase-js را اجرا کن و فایلی به نام .env.local با مقادیر زیر بساز. پیش از واردکردن گذرواژه‌های حساب‌های آزمایشی، .env.local را به .gitignore اضافه کن و آن را منتشر نکن:

SUPABASE_URL=https://...supabase.co
SUPABASE_PUBLIC_KEY=...
TEST_A_EMAIL=...
TEST_A_PASSWORD=...
TEST_B_EMAIL=...
TEST_B_PASSWORD=...

در SUPABASE_PUBLIC_KEY فقط کلید publishable یا anon قدیمی را بگذار. کد زیر را در rls-test.mjs ذخیره کن و در همان پوشه با node --env-file=.env.local rls-test.mjs اجرا کن. این اسکریپت در پروژهٔ آزمایشی رکورد می‌سازد و رکورد الف را در پایان حذف می‌کند:

import assert from 'node:assert/strict';
import { createClient } from '@supabase/supabase-js';

const e = process.env;
for (const name of [
  'SUPABASE_URL', 'SUPABASE_PUBLIC_KEY',
  'TEST_A_EMAIL', 'TEST_A_PASSWORD',
  'TEST_B_EMAIL', 'TEST_B_PASSWORD'
]) {
  if (!e[name]) throw new Error(`متغیر تنظیم نشده است: ${name}`);
}
const makeClient = () => createClient(e.SUPABASE_URL, e.SUPABASE_PUBLIC_KEY, {
  auth: { persistSession: false, autoRefreshToken: false }
});
const a = makeClient();
const b = makeClient();
const guest = makeClient();

async function login(client, email, password) {
  const { data, error } = await client.auth.signInWithPassword({ email, password });
  if (error) throw error;
  return data.user.id;
}
async function result(label, request) {
  const { data, error } = await request;
  if (error) throw new Error(`${label}: ${error.message}`);
  return data;
}

const [aUid, bUid] = await Promise.all([
  login(a, e.TEST_A_EMAIL, e.TEST_A_PASSWORD),
  login(b, e.TEST_B_EMAIL, e.TEST_B_PASSWORD)
]);
assert.notEqual(aUid, bUid, 'دو حساب باید متفاوت باشند');

const aRec = await result('درج الف', a.from('field_notes')
  .insert({ field_name: 'قطعهٔ شمالی', note: 'یادداشت الف' })
  .select('id').single());
const bRec = await result('درج ب', b.from('field_notes')
  .insert({ field_name: 'قطعهٔ جنوبی', note: 'یادداشت ب' })
  .select('id').single());
const [aRows, bRows] = await Promise.all([
  result('فهرست الف', a.from('field_notes').select('id')),
  result('فهرست ب', b.from('field_notes').select('id'))
]);
assert(aRows.some(row => row.id === aRec.id));
assert(!aRows.some(row => row.id === bRec.id));
assert(bRows.some(row => row.id === bRec.id));
assert(!bRows.some(row => row.id === aRec.id));

const guestRead = await guest.from('field_notes').select('id');
assert(guestRead.error || guestRead.data.length === 0);
assert((await guest.from('field_notes')
  .insert({ field_name: 'مهمان', note: 'نباید ثبت شود' })).error);

assert.equal((await result('خواندن الف با ب', b.from('field_notes')
  .select('id').eq('id', aRec.id))).length, 0);
assert.equal((await result('ویرایش الف با ب', b.from('field_notes')
  .update({ note: 'غیرمجاز' }).eq('id', aRec.id).select('id'))).length, 0);
assert.equal((await result('حذف الف با ب', b.from('field_notes')
  .delete().eq('id', aRec.id).select('id'))).length, 0);

const forgedInsert = await b.from('field_notes')
  .insert({ owner_id: aUid, field_name: 'مالک جعلی', note: 'رد شود' });
assert(forgedInsert.error, 'درج با مالک دیگر باید رد شود');
const forgedUpdate = await b.from('field_notes')
  .update({ owner_id: aUid }).eq('id', bRec.id).select('id');
assert(forgedUpdate.error, 'تغییر مالک رکورد خود ب باید رد شود');

assert.equal((await result('ویرایش توسط الف', a.from('field_notes')
  .update({ note: 'اصلاح شد' }).eq('id', aRec.id).select('id'))).length, 1);
assert.equal((await result('حذف توسط الف', a.from('field_notes')
  .delete().eq('id', aRec.id).select('id'))).length, 1);
console.log('آزمون‌های مالکیت و دسترسی مهمان با انتظارها سازگار بودند.');

اسکریپت دو نشست مستقل می‌سازد، نبود دسترسی مهمان را بررسی می‌کند و نشان می‌دهد ب نمی‌تواند رکورد الف را بخواند، ویرایش کند یا حذف کند. سپس درج با owner_id الف و تغییر مالک رکورد خودِ ب را امتحان می‌کند؛ این دو درخواست باید به‌دلیل WITH CHECK خطا بدهند. پیام پایانی فقط نتیجهٔ اجرای موفق اسکریپت در پروژهٔ خودت است، نه شاهدی بر اینکه پنهان‌کردن دکمه‌ها در UI مجوز دسترسی ایجاد می‌کند.

۹. مدیر را با مالکِ رکورد اشتباه نگیر

در نمونهٔ پروژه، نقش‌هایی مانند مدیر و اپراتور مطرح شد و بررسی رکورد توسط مدیر هم بخشی از نیاز محصول بود. اما چهار سیاست آموزشی بالا عمداً به مدیر دسترسی به همهٔ رکوردها نمی‌دهند. اگر یک حساب مدیر با همین نقش دیتابیس و همین سیاست‌ها وارد شود، باز هم فقط رکوردهای خودش را می‌بیند. این رفتار، نقص مثال نیست؛ محدودهٔ آگاهانهٔ آن است.

برای دسترسی مدیریتی باید جداگانه تعیین کنی مدیر دقیقاً چه چیزی را، برای کدام مزرعه و با چه عملیاتی می‌بیند. همچنین باید مشخص باشد چه کسی نقش مدیر را تعیین می‌کند. صرف اینکه کلاینت بتواند مقدار role را در یک جدول پروفایل بنویسد، راه امنِ اعطای مجوز نیست. برای نسخهٔ ابتدایی، بهتر است دسترسی مدیر را تا طراحی و آزمون سیاست مستقل، به این مثال اضافه نکنی؛ استفاده از service_role داخل مرورگر برای «حل سریع» این مشکل، مرز امنیتی را از بین می‌برد.

۱۰. خطاها را از روی نشانه‌شان جدا کن و نتیجه را تحویل بده

اگر کاربر وارد نشده و ثبت انجام نمی‌شود، نخست نشست Auth را بررسی کن؛ auth.uid() برای درخواست بدون هویت، شناسهٔ مالک معتبری فراهم نمی‌کند. اگر کاربر وارد شده ولی درج رد می‌شود، مقدار owner_id ارسالی، فعال‌بودن RLS و سیاست INSERT را بررسی کن. اگر ویرایشِ رکورد خودت کار نمی‌کند، علاوه بر UPDATE، امکان خواندن آن ردیف با سیاست SELECT را هم کنترل کن. اگر هر دو حساب ناگهان دادهٔ یکسان می‌بینند، پیش از تغییر ظاهر اپ، بررسی کن RLS برای جدول فعال است، سیاست SELECT برقرار است و درخواست‌ها واقعاً با کدام نشست و کدام سطح دسترسی ارسال می‌شوند.

خطای دیگر، فنی نیست بلکه مربوط به فرایند توسعه است: اتصال دیتابیس ممکن است هنگام بازنویسی رابط، فرم یا صف آفلاین قبلی را خراب کند. تغییرات را با نسخهٔ قبلی مقایسه کن و مسیر «ورود ← ثبت ← نمایش ← خروج» را دوباره از ابتدا طی کن. موفق‌شدن یک درج در جدول، به معنی سالم‌ماندن کل اپ نیست.

خروجی قابل‌تحویل تو یک پروژهٔ آزمایشی با جدول field_notes و چهار سیاست بالا، به‌همراه یادداشت کوتاهی از آزمون دو حساب است: هر حساب چه چیزی ثبت کرد، چه چیزی در فهرست دید و تلاش برای دسترسی به رکورد حساب دیگر چه نتیجه‌ای داشت. اگر بعداً خواستی همین الگو را به رکوردهای واقعی مزرعه ببری، ابتدا مالکیت، رابطه با مزرعه و نیاز دقیق مدیر را روی کاغذ مشخص کن؛ بعد شِما و سیاست‌های تازه را متناسب با همان نیاز طراحی کن.

مطالعهٔ مرتبط

طراحی دیتابیس با Supabase و SQL؛ از فرم ساده تا دادهٔ قابل‌اعتماد

ویدیوی کامل

اگر می‌خواهی روند کار و آزمون‌ها را هم ببینی، ویدیوی کامل این آموزش اینجاست:

تماشای ویدیو در یوتیوب · گروه گفت‌وگو و پرسش‌های آموزشی


دیدگاهتان را بنویسید