مشروع: تطبيق ملاحظات للفرق بـ Supabase — Supabase
مشروع: تطبيق ملاحظات للفرق بـ Supabase
تطبيق تتشارك فيه فرق صغيرة ملاحظاتها. التحدي الحقيقي ليس الواجهة بل الصلاحيات: كل عضو يرى ملاحظات فرقه فقط، والمالك وحده يدير الأعضاء — وكل ذلك تفرضه قاعدة البيانات نفسها لأن الواجهة تتصل بها مباشرة بمفتاح علني.
الجداول والأدوار
teams ──< team_members (role: owner | editor | viewer) >── auth.users
teams ──< notes (author_id) ──< attachments (في Storage: team-files/{team_id}/...)المبدأ: دالة مساعدة للعضوية
سياسات كثيرة تسأل نفس السؤال: "هل المستخدم عضو في هذا الفريق؟ بأي دور؟". دالة واحدة SECURITY DEFINER تجيب، فتتجنب تكرار المنطق وتتجنب حلقة RLS على جدول الأعضاء نفسه:
CREATE TABLE teams (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL);
CREATE TABLE team_members (
team_id uuid REFERENCES teams(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')),
PRIMARY KEY (team_id, user_id)
);
CREATE FUNCTION public.team_role(p_team uuid)
RETURNS text
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = ''
AS $$
SELECT role FROM public.team_members WHERE team_id = p_team AND user_id = auth.uid();
$$;
REVOKE EXECUTE ON FUNCTION public.team_role(uuid) FROM anon;SET search_path = '' وأسماء الجداول الكاملة (public.team_members) يمنعان استغلال search_path في دوال SECURITY DEFINER.
سياسات الملاحظات
| العملية | من يُسمح له |
|---|---|
| قراءة | أي عضو في الفريق |
| إنشاء | owner و editor، و author_id = المستخدم نفسه |
| تعديل | owner، أو editor على ملاحظاته فقط |
| حذف | owner فقط |
المثال الكامل يكتب الجداول والسياسات كاملة، ويُنفَّذ على PostgreSQL.
Storage بنفس المنطق
المسار team-files/{team_id}/{note_id}/file.pdf، والسياسة تتحقق من العضوية عبر أول جزء من المسار:
CREATE POLICY "members read team files" ON storage.objects FOR SELECT TO authenticated
USING (bucket_id = 'team-files' AND public.team_role(((storage.foldername(name))[1])::uuid) IS NOT NULL);(تُنفَّذ في مشروع Supabase حقيقي حيث يوجد مخطط storage.)
اللحظية
الواجهة تشترك في postgres_changes على notes بفلتر team_id=eq.<id> — و Realtime يحترم RLS، فلا يستقبل غير الأعضاء شيئاً.
الدعوات: Edge Function
إضافة عضو بالبريد تحتاج البحث عن مستخدم بالبريد — وهذا لا يجوز من الواجهة (كشف قائمة المستخدمين). دالة Edge:
1. تتحقق أن المستدعي owner في الفريق (بعميل يحمل رمزه، عبر team_role).
2. بمفتاح service_role: تبحث عن المستخدم أو ترسل دعوة (auth.admin.inviteUserByEmail).
3. تضيف صف العضوية، وتحد عدد الدعوات لكل فريق يومياً.
اختبار السياسات
لا تكتفِ بالتجربة من الواجهة. في SQL Editor أو بأداة pgTAP: انتحل مستخدماً (set local role authenticated; set local request.jwt.claim.sub = '...') ونفّذ الاستعلامات المتوقع رفضها. المثال الكامل ينتهي بهذه الاختبارات.
قائمة الإنجاز
- [ ] RLS مفعّل على كل الجداول، ولا جدول بلا سياسات مقصودة.
- [ ] viewer لا يُنشئ، و editor لا يعدّل ملاحظات غيره ولا يحذف، وغير العضو لا يرى شيئاً.
- [ ] لا مفتاح service_role في الواجهة.
- [ ] دالة الدعوة تتحقق من الدور وتحد المعدل.
- [ ] الأنواع مولّدة لـ TypeScript (supabase gen types).
-- ===== الجداول =====
CREATE TABLE teams (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 80),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE team_members (
team_id uuid NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')),
PRIMARY KEY (team_id, user_id)
);
CREATE TABLE notes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
team_id uuid NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
author_id uuid NOT NULL DEFAULT auth.uid() REFERENCES auth.users(id),
title text NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
body text NOT NULL DEFAULT '',
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX notes_team_idx ON notes(team_id, updated_at DESC);
-- ===== دالة العضوية (تتجنب حلقة RLS على team_members) =====
CREATE FUNCTION public.team_role(p_team uuid)
RETURNS text LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
SELECT role FROM public.team_members WHERE team_id = p_team AND user_id = auth.uid();
$$;
REVOKE EXECUTE ON FUNCTION public.team_role(uuid) FROM anon;
GRANT EXECUTE ON FUNCTION public.team_role(uuid) TO authenticated;
-- ===== RLS =====
ALTER TABLE teams ENABLE ROW LEVEL SECURITY;
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY "members see team" ON teams FOR SELECT TO authenticated USING (public.team_role(id) IS NOT NULL);
CREATE POLICY "members see members" ON team_members FOR SELECT TO authenticated USING (public.team_role(team_id) IS NOT NULL);
CREATE POLICY "owner manages members" ON team_members FOR ALL TO authenticated
USING (public.team_role(team_id) = 'owner') WITH CHECK (public.team_role(team_id) = 'owner');
CREATE POLICY "members read notes" ON notes FOR SELECT TO authenticated USING (public.team_role(team_id) IS NOT NULL);
CREATE POLICY "editors create notes" ON notes FOR INSERT TO authenticated
WITH CHECK (public.team_role(team_id) IN ('owner', 'editor') AND author_id = auth.uid());
CREATE POLICY "edit own or owner" ON notes FOR UPDATE TO authenticated
USING (public.team_role(team_id) = 'owner' OR (public.team_role(team_id) = 'editor' AND author_id = auth.uid()))
WITH CHECK (public.team_role(team_id) IN ('owner', 'editor'));
CREATE POLICY "owner deletes" ON notes FOR DELETE TO authenticated USING (public.team_role(team_id) = 'owner');
GRANT SELECT, INSERT, UPDATE, DELETE ON teams, team_members, notes TO authenticated;
-- ===== اختبار السياسات: بيانات ثم انتحال مستخدمين =====
INSERT INTO auth.users (id, email) VALUES
('00000000-0000-0000-0000-00000000000a', 'owner@example.com'),
('00000000-0000-0000-0000-00000000000b', 'viewer@example.com'),
('00000000-0000-0000-0000-00000000000c', 'stranger@example.com');
INSERT INTO teams (id, name) VALUES ('11111111-1111-1111-1111-111111111111', 'فريق Kodax');
INSERT INTO team_members VALUES
('11111111-1111-1111-1111-111111111111', '00000000-0000-0000-0000-00000000000a', 'owner'),
('11111111-1111-1111-1111-111111111111', '00000000-0000-0000-0000-00000000000b', 'viewer');
INSERT INTO notes (team_id, author_id, title) VALUES
('11111111-1111-1111-1111-111111111111', '00000000-0000-0000-0000-00000000000a', 'خطة الإطلاق');
-- الغريب لا يرى شيئاً
BEGIN;
SET LOCAL ROLE authenticated;
SELECT set_config('request.jwt.claim.sub', '00000000-0000-0000-0000-00000000000c', true);
DO $$ BEGIN
IF (SELECT count(*) FROM notes) <> 0 THEN RAISE EXCEPTION 'تسريب: الغريب يرى الملاحظات'; END IF;
END $$;
COMMIT;
-- المشاهد يقرأ ولا يُنشئ
BEGIN;
SET LOCAL ROLE authenticated;
SELECT set_config('request.jwt.claim.sub', '00000000-0000-0000-0000-00000000000b', true);
DO $$ BEGIN
IF (SELECT count(*) FROM notes) <> 1 THEN RAISE EXCEPTION 'المشاهد يجب أن يرى ملاحظة واحدة'; END IF;
BEGIN
INSERT INTO notes (team_id, title) VALUES ('11111111-1111-1111-1111-111111111111', 'محاولة');
RAISE EXCEPTION 'خطأ: المشاهد استطاع الإنشاء';
EXCEPTION WHEN insufficient_privilege THEN NULL; -- رفض RLS متوقع
END;
END $$;
COMMIT;اختبر فهمك