Kodax

مشروع: تطبيق ملاحظات للفرق بـ 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;
اختبر فهمك