حماية بياناتك في Firestore باستخدام "قواعد الأمان" في Firebase

1- قبل البدء

تعتمد Cloud Firestore وCloud Storage for Firebase وقاعدة البيانات في الوقت الفعلي على ملفات الإعداد التي تكتبها لمنح إمكانية الوصول للقراءة والكتابة. يمكن أن تعمل هذه الإعدادات، المسماة "قواعد الأمان"، أيضًا كنوع من المخططات لتطبيقك. وهو أحد أهم أجزاء تطوير التطبيق. وسيشرح لك هذا الدرس التطبيقي حول الترميز كيفية استخدامها.

المتطلبات الأساسية

  • أداة تحرير بسيطة مثل Visual Studio Code أو Atom أو Sublime Text
  • Node.js 8.6.0 أو إصدار أحدث (لتثبيت Node.js، استخدم nvm؛ للتحقق من الإصدار، شغِّل node --version)
  • Java 7 أو إصدار أحدث (لتثبيت Java، يُرجى استخدام هذه التعليمات، أو شغِّل java -version للتحقّق من الإصدار)

الأنشطة

في هذا الدرس التطبيقي حول الترميز، ستؤمّن منصة مدونات بسيطة تم تصميمها استنادًا إلى Firestore. ستستخدم محاكي Firestore لإجراء اختبارات الوحدات للتأكّد من توافقه مع "قواعد الأمان" والتأكّد من أنّ هذه القواعد تسمح بالوصول المتوقّع ولا تسمح به.

وستتعرّف على كيفية:

  • منح أذونات دقيقة
  • فرض عمليات التحقق من صحة البيانات والأنواع
  • تنفيذ التحكم في الوصول المستند إلى السمات
  • منح إمكانية الوصول استنادًا إلى طريقة المصادقة
  • إنشاء دوال مخصصة
  • إنشاء قواعد أمان مستندة إلى الوقت
  • تنفيذ قائمة الحظر وعمليات الحذف الأولية
  • التعرّف على حالات إلغاء تطبيع البيانات لاستيفاء أنماط الوصول المتعددة

2- إعداد

هذا تطبيق تدوين. في ما يلي ملخّص عالي المستوى لوظائف التطبيق:

مسودات مشاركات المدونة:

  • يمكن للمستخدمين إنشاء مسودة مشاركات مدونة، تظهر في مجموعة drafts.
  • ويمكن للمؤلف مواصلة تعديل مسودة إلى أن تصبح جاهزة للنشر.
  • عندما تكون جاهزة للنشر، يتم تشغيل دالة Firebase تؤدي إلى إنشاء مستند جديد في مجموعة published.
  • يمكن للمؤلف أو مشرفي الموقع الإلكتروني حذف المسودات.

مشاركات المدونة المنشورة:

  • لا يمكن للمستخدمين إنشاء المشاركات المنشورة، بل يمكن إجراء ذلك فقط باستخدام دالة.
  • ويمكن فقط حذفها مبدئيًا، ما يؤدي إلى تعديل السمة visible إلى "خطأ".

التعليقات

  • تسمح المشاركات المنشورة بالتعليقات، وهي عبارة عن مجموعة فرعية في كل مشاركة منشورة.
  • للحدّ من إساءة الاستخدام، يجب أن يكون لدى المستخدمين عنوان بريد إلكتروني تم إثبات ملكيته وألّا تكون جهة الرفض من أجل إضافة تعليق.
  • لا يمكن تعديل التعليقات إلا بعد ساعة من نشرها.
  • ويمكن لمؤلف التعليق أو مؤلف المشاركة الأصلية أو المشرفين حذف التعليقات.

بالإضافة إلى قواعد الوصول، عليك إنشاء "قواعد أمان" تفرض الحقول المطلوبة وعمليات التحقّق من صحة البيانات.

وكل شيء سيحدث محليًا باستخدام "حزمة محاكاة Firebase".

الحصول على رمز المصدر

في هذا الدرس التطبيقي حول الترميز، ستبدأ باختبارات حول "قواعد الأمان"، ولكن ستشبه قواعد الأمان نفسها، لذا عليك أولاً استنساخ المصدر لإجراء الاختبارات:

$ git clone https://github.com/FirebaseExtended/codelab-rules.git

بعد ذلك، انتقِل إلى دليل الحالة الأولية، حيث ستعمل خلال باقي هذا الدرس التطبيقي:

$ cd codelab-rules/initial-state

الآن، قم بتثبيت التبعيات حتى تتمكن من تشغيل الاختبارات. وإذا كان اتصال الإنترنت بطيئًا، قد يستغرق ذلك دقيقة أو اثنتين:

# Move into the functions directory, install dependencies, jump out.
$ cd functions && npm install && cd -

الحصول على واجهة سطر الأوامر في Firebase

تعتبر "مجموعة أدوات المحاكاة" التي ستستخدمها لإجراء الاختبارات جزءًا من واجهة سطر الأوامر في Firebase (واجهة سطر الأوامر) التي يمكن تثبيتها على جهازك باستخدام الأمر التالي:

$ npm install -g firebase-tools

بعد ذلك، تأكَّد من استخدام أحدث إصدار من واجهة سطر الأوامر. من المفترض أن يعمل هذا الدرس التطبيقي مع الإصدار 8.4.0 أو الإصدارات الأحدث، ولكنّ الإصدارات الأحدث تتضمّن المزيد من إصلاحات الأخطاء.

$ firebase --version
9.10.2

3- إجراء الاختبارات

في هذا القسم، يمكنك إجراء الاختبارات محليًا. وهذا يعني أن الوقت قد حان لبدء تشغيل مجموعة أدوات المحاكاة.

تشغيل المحاكيات

يحتوي التطبيق الذي ستعمل عليه على ثلاث مجموعات رئيسية في Firestore: drafts تحتوي على مشاركات مدونات قيد التقدم، وتحتوي مجموعة published على مشاركات المدونات التي تم نشرها، وcomments هي مجموعة فرعية تتناول المشاركات المنشورة. يشمل المستودع الإعلاني اختبارات الوحدة لقواعد الأمان التي تحدّد سمات المستخدم والشروط الأخرى المطلوبة لكي يتمكّن المستخدم من إنشاء المستندات وقراءتها وتعديلها وحذفها في مجموعات drafts وpublished وcomments. ستكتب قواعد الأمان لاجتياز هذه الاختبارات.

للبدء، يتم تأمين قاعدة البيانات: يتم رفض القراءة والكتابة في قاعدة البيانات بشكل عام، وتفشل جميع الاختبارات. عند كتابة قواعد الأمان، ستمر الاختبارات. للاطّلاع على الاختبارات، افتح functions/test.js في المحرِّر.

في سطر الأوامر، ابدأ أدوات المحاكاة باستخدام emulators:exec ونفِّذ الاختبارات التالية:

$ firebase emulators:exec --project=codelab --import=.seed "cd functions; npm test"

انتقِل إلى أعلى النتيجة:

$ firebase emulators:exec --project=codelab --import=.seed "cd functions; npm test"
i  emulators: Starting emulators: functions, firestore, hosting
⚠  functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: auth, database, pubsub
⚠  functions: Unable to fetch project Admin SDK configuration, Admin SDK behavior in Cloud Functions emulator may be incorrect.
i  firestore: Importing data from /Users/user/src/firebase/rules-codelab/initial-state/.seed/firestore_export/firestore_export.overall_export_metadata
i  firestore: Firestore Emulator logging to firestore-debug.log
⚠  hosting: Authentication error when trying to fetch your current web app configuration, have you run firebase login?
⚠  hosting: Could not fetch web app configuration and there is no cached configuration on this machine. Check your internet connection and make sure you are authenticated. To continue, you must call firebase.initializeApp({...}) in your code before using Firebase.
i  hosting: Serving hosting files from: public
✔  hosting: Local server: http://localhost:5000
i  functions: Watching "/Users/user/src/firebase/rules-codelab/initial-state/functions" for Cloud Functions...
✔  functions[publishPost]: http function initialized (http://localhost:5001/codelab/us-central1/publishPost).
✔  functions[softDelete]: http function initialized (http://localhost:5001/codelab/us-central1/softDelete).
i  Running script: pushd functions; npm test
~/src/firebase/rules-codelab/initial-state/functions ~/src/firebase/rules-codelab/initial-state

> functions@ test /Users/user/src/firebase/rules-codelab/initial-state/functions
> mocha

(node:76619) ExperimentalWarning: Conditional exports is an experimental feature. This feature could change at any time


  Draft blog posts
    1) can be created with required fields by the author
    2) can be updated by author if immutable fields are unchanged
    3) can be read by the author and moderator

  Published blog posts
    4) can be read by everyone; created or deleted by no one
    5) can be updated by author or moderator

  Comments on published blog posts
    6) can be read by anyone with a permanent account
    7) can be created if email is verfied and not blocked
    8) can be updated by author for 1 hour after creation
    9) can be deleted by an author or moderator


  0 passing (848ms)
  9 failing

...

حاليًا هناك 9 إخفاقات. أثناء إنشاء ملف القواعد، يمكنك قياس مستوى التقدّم من خلال مشاهدة المزيد من الاختبارات التي تم اجتيازها.

4. إنشاء مسودات لمشاركات المدوّنة

نظرًا لاختلاف إمكانية الوصول إلى مسودة مشاركات المدونة عن الوصول إلى مشاركات المدونة المنشورة، يخزِّن تطبيق التدوين هذا مسودات مشاركات المدونة في مجموعة منفصلة، /drafts. يمكن فقط للمؤلف أو المشرف الوصول إلى المسودات، كما يتم إجراء عمليات تحقق من صحة الحقول المطلوبة وغير القابلة للتغيير.

عند فتح ملف firestore.rules، سيظهر لك ملف قواعد تلقائي:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

تستخدم عبارة المطابقة match /{document=**} البنية ** لتطبيقها بشكل متكرر على جميع المستندات في المجموعات الفرعية. ولأنّها في المستوى الأعلى، تنطبق الآن القاعدة العامة نفسها على جميع الطلبات، بغضّ النظر عن مقدّم الطلب أو البيانات التي يحاول قراءتها أو كتابتها.

ابدأ بإزالة عبارة المطابقة الأعمق واستبدالها بـ match /drafts/{draftID}. (يمكن أن تكون تعليقات بنية المستندات مفيدة في القواعد، وسيتم تضمينها في هذا الدرس التطبيقي حول الترميز، وتكون اختيارية دائمًا.)

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional
    }
  }
}

ستتحكّم القاعدة الأولى التي ستكتبها للمسودات في الأشخاص الذين يمكنهم إنشاء المستندات. في هذا التطبيق، لا يمكن إنشاء المسودات إلا من قِبل الشخص المدرج كمؤلف. تأكَّد من أنّ المعرّف الفريد الخاص بالمستخدم الذي يقدّم الطلب هو المعرّف الفريد نفسه الوارد في المستند.

سيكون الشرط الأول للإنشاء هو:

request.resource.data.authorUID == request.auth.uid

بعد ذلك، لا يمكن إنشاء المستندات إلا إذا كانت تتضمّن الحقول الثلاثة المطلوبة، وهي authorUID وcreatedAt وtitle. (لا يوفّر المستخدم الحقل createdAt، ويعني ذلك أنّه يجب على التطبيق إضافته قبل محاولة إنشاء مستند). بما أنّك تحتاج فقط إلى التأكد من أنّه يتم إنشاء السمات، يمكنك التأكّد من أنّ request.resource يتضمن كل هذه المفاتيح:

request.resource.data.keys().hasAll([
  "authorUID",
  "createdAt",
  "title"
])

الشرط الأخير لإنشاء مشاركة مدونة هو ألّا يزيد طول العنوان عن 50 حرفًا:

request.resource.data.title.size() < 50

وبما أنّ جميع هذه الشروط يجب أن تكون صحيحة، يجب ربط هذه الشروط مع عامل التشغيل AND المنطقي &&. تصبح القاعدة الأولى:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;
    }
  }
}

إعادة إجراء الاختبارات في الوحدة الطرفية والتأكد من نجاح الاختبار الأول

5- تعديل مسودّات مشاركات المدونة

بعد ذلك، عندما يحسّن المؤلفون مسودات مشاركات المدونات، فسيتم تعديل مسودّات المستندات. أنشئ قاعدة لشروط إمكانية تعديل المشاركة. أولاً، يمكن للمؤلف فقط تحديث مسوداته. يُرجى العِلم أنّه يمكنك هنا التحقّق من المعرّف الفريد الذي تمت كتابته،resource.data.authorUID:

resource.data.authorUID == request.auth.uid

الشرط الثاني للتحديث هو عدم تغيير السمتَين authorUID وcreatedAt:

request.resource.data.diff(resource.data).unchangedKeys().hasAll([
    "authorUID",
    "createdAt"
]);

وأخيرًا، يجب ألا يزيد عدد أحرف العنوان عن 50 حرفًا:

request.resource.data.title.size() < 50;

بما أنّه يجب استيفاء جميع الشروط، يجب إجراء تسلسل لها مع &&:

allow update: if
  // User is the author, and
  resource.data.authorUID == request.auth.uid &&
  // `authorUID` and `createdAt` are unchanged
  request.resource.data.diff(resource.data).unchangedKeys().hasAll([
    "authorUID",
    "createdAt"
  ]) &&
  // Title must be < 50 characters long
  request.resource.data.title.size() < 50;

تصبح القواعد الكاملة:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;
    }
  }
}

أعِد إجراء الاختبارات وتأكَّد من اجتياز اختبار آخر.

6- حذف المسودات وقراءتها: التحكم في الوصول المستند إلى السمات

ومثلما يمكن للمؤلفين إنشاء المسودات وتعديلها، يمكنهم أيضًا حذف المسودات.

resource.data.authorUID == request.auth.uid

بالإضافة إلى ذلك، يُسمح للمؤلفين الذين لديهم السمة isModerator في الرمز المميّز للمصادقة بحذف المسودات:

request.auth.token.isModerator == true

بما أنّ أيًا من هذين الشرطين كاف للحذف، يمكنك إجراء تسلسل له مع عامل تشغيل OR منطقي، يكون ||:

allow delete: if resource.data.authorUID == request.auth.uid || request.auth.token.isModerator == true

تنطبق نفس الشروط على القراءات، بحيث يمكن إضافة الإذن إلى القاعدة:

allow read, delete: if resource.data.authorUID == request.auth.uid || request.auth.token.isModerator == true

في ما يلي القواعد الكاملة:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow read, delete: if
        // User is draft author
        resource.data.authorUID == request.auth.uid ||
        // User is a moderator
        request.auth.token.isModerator == true;
    }
  }
}

أعِد إجراء الاختبارات وتأكَّد من اجتياز اختبار آخر بنجاح.

7- قراءة المشاركات المنشورة وإنشاؤها وحذفها: إلغاء الضبط لأنماط الوصول المختلفة

نظرًا لاختلاف أنماط الوصول للمشاركات المنشورة ومسودّات المشاركات، يغيّر هذا التطبيق ترتيب المشاركات إلى مجموعات منفصلة من draft وpublished. على سبيل المثال، يمكن لأي شخص قراءة المشاركات المنشورة ولكن لا يمكن حذفها نهائيًا، بينما يمكن حذف المسودات ولكن لا يمكن قراءتها إلا من خلال المؤلف والمشرفين. في هذا التطبيق، عندما يريد أحد المستخدمين نشر مسودة منشور المدوّنة، يتم تشغيل دالة ستنشئ المشاركة الجديدة المنشورة.

بعد ذلك، ستكتب القواعد للمشاركات المنشورة. أبسط قواعد الكتابة هي أنّه يمكن لأي شخص قراءة المشاركات المنشورة، ولا يمكن لأي شخص إنشاءها أو حذفها. أضِف القواعد التالية:

match /published/{postID} {
  // `authorUID`: string, required
  // `content`: string, required
  // `publishedAt`: timestamp, required
  // `title`: string, < 50 characters, required
  // `url`: string, required
  // `visible`: boolean, required

  // Can be read by everyone
  allow read: if true;

  // Published posts are created only via functions, never by users
  // No hard deletes; soft deletes update `visible` field.
  allow create, delete: if false;
}

عند إضافة هذه القواعد إلى القواعد الحالية، يصبح ملف القواعد بالكامل:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow read, delete: if
        // User is draft author
        resource.data.authorUID == request.auth.uid ||
        // User is a moderator
        request.auth.token.isModerator == true;
    }

    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;
    }
  }
}

إعادة إجراء الاختبارات والتأكّد من اجتياز اختبار آخر بنجاح

8- تعديل المشاركات المنشورة: الدوال المخصّصة والمتغيّرات المحلية

شروط تعديل مشاركة منشورة هي:

  • يمكن للمؤلف أو المشرف فقط تنفيذ ذلك
  • يجب أن يحتوي على جميع الحقول المطلوبة.

نظرًا لأنك كتبت شروطًا بالفعل لكي تكون مؤلفًا أو مشرفًا، يمكنك نسخ الشروط ولصقها، ولكن مع مرور الوقت قد يصبح من الصعب قراءتها وصيانتها. بدلاً من ذلك، ستقوم بإنشاء دالة مخصصة تضم المنطق لكونك مؤلفًا أو مشرفًا. وبعد ذلك، تطلبها من شروط متعددة.

إنشاء دالة مخصصة

أعلى عبارة المطابقة للمسودات، أنشئ دالة جديدة تسمى isAuthorOrModerator تُستخدم كوسيطات في مستند مشاركة (يمكن استخدام ذلك مع المسودات أو المشاركات المنشورة) وكائن مصادقة المستخدم:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {

    }

    match /drafts/{postID} {
      allow create: ...
      allow update: ...
      ...
    }

    match /published/{postID} {
      allow read: ...
      allow create, delete: ...
    }
  }
}

استخدام المتغيّرات المحلية

داخل الدالة، استخدِم الكلمة الرئيسية let لضبط المتغيّرَين isAuthor وisModerator. يجب أن تنتهي جميع الدوال بعبارة إرجاع، وستعرض دوالنا قيمة منطقية تشير إلى ما إذا كان أي من المتغير true:

function isAuthorOrModerator(post, auth) {
  let isAuthor = auth.uid == post.authorUID;
  let isModerator = auth.token.isModerator == true;
  return isAuthor || isModerator;
}

استدعِ الدالة.

ستعدِّل الآن قاعدة المسودات لاستدعاء هذه الدالة، مع الحرص على تمرير resource.data كوسيطة أولى:

  // Draft blog posts
  match /drafts/{draftID} {
    ...
    // Can be deleted by author or moderator
    allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
  }

يمكنك الآن كتابة شرط لتحديث المشاركات المنشورة التي تستخدم أيضًا الدالة الجديدة:

allow update: if isAuthorOrModerator(resource.data, request.auth);

إضافة عمليات تحقُّق

يجب عدم تغيير بعض حقول المشاركة المنشورة، وتحديدًا الحقول url وauthorUID وpublishedAt غير القابلة للتغيير. ويجب أن يظل الحقلان الآخران، title وcontent وvisible موجودين بعد إجراء تحديث. أضِف شروطًا لفرض هذه المتطلبات على المشاركات المنشورة:

// Immutable fields are unchanged
request.resource.data.diff(resource.data).unchangedKeys().hasAll([
  "authorUID",
  "publishedAt",
  "url"
]) &&
// Required fields are present
request.resource.data.keys().hasAll([
  "content",
  "title",
  "visible"
])

إنشاء دالة مخصّصة بنفسك

وأخيرًا، أضف شرطًا بأن يكون العنوان أقل من 50 حرفًا. ولأنّ هذا المنطق مُعاد استخدامه، يمكنك إجراء ذلك عن طريق إنشاء دالة جديدة، titleIsUnder50Chars. باستخدام الدالة الجديدة، يصبح شرط تحديث المشاركة المنشورة:

allow update: if
  isAuthorOrModerator(resource.data, request.auth) &&
  // Immutable fields are unchanged
  request.resource.data.diff(resource.data).unchangedKeys().hasAll([
    "authorUID",
    "publishedAt",
    "url"
  ]) &&
  // Required fields are present
  request.resource.data.keys().hasAll([
    "content",
    "title",
    "visible"
  ]) &&
  titleIsUnder50Chars(request.resource.data);

وملف القاعدة الكامل هو:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {
      let isAuthor = auth.uid == post.authorUID;
      let isModerator = auth.token.isModerator == true;
      return isAuthor || isModerator;
    }

    function titleIsUnder50Chars(post) {
      return post.title.size() < 50;
    }

    // Draft blog posts
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        titleIsUnder50Chars(request.resource.data);

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
          ]) &&
        titleIsUnder50Chars(request.resource.data);

      // Can be read or deleted by author or moderator
      allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
    }

    // Published blog posts are denormalized from drafts
    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;

      allow update: if
        isAuthorOrModerator(resource.data, request.auth) &&
        // Immutable fields are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "publishedAt",
          "url"
        ]) &&
        // Required fields are present
        request.resource.data.keys().hasAll([
          "content",
          "title",
          "visible"
        ]) &&
        titleIsUnder50Chars(request.resource.data);
    }
  }
}

إعادة إجراء الاختبارات في هذه المرحلة، يجب أن يكون لديك 5 اختبارات ناجحة و4 اختبارات فاشلة.

9- التعليقات: المجموعات الفرعية وأذونات موفِّر تسجيل الدخول

تسمح المشاركات المنشورة بالتعليقات، ويتم تخزين التعليقات في مجموعة فرعية من المشاركة المنشورة (/published/{postID}/comments/{commentID}). لا تنطبق قواعد المجموعة تلقائيًا على المجموعات الفرعية. لنفترض أنك لا تريد تطبيق القواعد نفسها التي تنطبق على المستند الرئيسي للمشاركة المنشورة على التعليقات، ستقوم بصياغة إطارات مختلفة.

لكتابة قواعد الوصول إلى التعليقات، ابدأ بعبارة المطابقة كما يلي:

match /published/{postID}/comments/{commentID} {
  // `authorUID`: string, required
  // `comment`: string, < 500 characters, required
  // `createdAt`: timestamp, required
  // `editedAt`: timestamp, optional

قراءة التعليقات: لا يمكن إخفاء الهوية

بالنسبة إلى هذا التطبيق، يمكن فقط للمستخدمين الذين أنشأوا حسابًا دائمًا وليس حسابًا مجهولاً قراءة التعليقات. لفرض هذه القاعدة، ابحث عن السمة sign_in_provider في كل عنصر auth.token:

allow read: if request.auth.token.firebase.sign_in_provider != "anonymous";

أعِد إجراء الاختبارات وتأكَّد من اجتياز اختبار آخر.

إنشاء التعليقات: التحقّق من قائمة الحظر

هناك ثلاثة شروط لإنشاء تعليق:

  • يجب أن يمتلك المستخدم بريدًا إلكترونيًا تم التحقق منه
  • يجب ألا يزيد عدد أحرف التعليق عن 500 حرف
  • لا يمكن إدراجه في قائمة المستخدمين المحظورين، والتي يتم تخزينها في متجر Firestore في مجموعة bannedUsers. أخذ هذه الحالات واحدة تلو الأخرى:
request.auth.token.email_verified == true
request.resource.data.comment.size() < 500
!exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));

القاعدة الأخيرة لإنشاء التعليقات هي:

allow create: if
  // User has verified email
  (request.auth.token.email_verified == true) &&
  // UID is not on bannedUsers list
  !(exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));

أصبح ملف القواعد بالكامل الآن:

For bottom of step 9
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {
      let isAuthor = auth.uid == post.authorUID;
      let isModerator = auth.token.isModerator == true;
      return isAuthor || isModerator;
    }

    function titleIsUnder50Chars(post) {
      return post.title.size() < 50;
    }

    // Draft blog posts
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User is author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and createdAt fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        titleIsUnder50Chars(request.resource.data);

      allow update: if
        // User is author
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
          ]) &&
        titleIsUnder50Chars(request.resource.data);

      // Can be read or deleted by author or moderator
      allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
    }

    // Published blog posts are denormalized from drafts
    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;

      allow update: if
        isAuthorOrModerator(resource.data, request.auth) &&
        // Immutable fields are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "publishedAt",
          "url"
        ]) &&
        // Required fields are present
        request.resource.data.keys().hasAll([
          "content",
          "title",
          "visible"
        ]) &&
        titleIsUnder50Chars(request.resource.data);
    }

    match /published/{postID}/comments/{commentID} {
      // `authorUID`: string, required
      // `createdAt`: timestamp, required
      // `editedAt`: timestamp, optional
      // `comment`: string, < 500 characters, required

      // Must have permanent account to read comments
      allow read: if !(request.auth.token.firebase.sign_in_provider == "anonymous");

      allow create: if
        // User has verified email
        request.auth.token.email_verified == true &&
        // Comment is under 500 characters
        request.resource.data.comment.size() < 500 &&
        // UID is not on the block list
        !exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));
    }
  }
}

أعِد إجراء الاختبارات وتأكَّد من اجتياز اختبار آخر.

10- تعديل التعليقات: القواعد المستندة إلى الوقت

يتمثّل منطق العمل الخاص بالتعليقات في أنّه يمكن لمؤلف التعليق تعديلها لمدة ساعة بعد إنشائها. لتنفيذ ذلك، استخدِم الطابع الزمني للسمة createdAt.

أولاً، لإثبات أنّ المستخدم هو المؤلف:

request.auth.uid == resource.data.authorUID

بعد ذلك، أنّه تم إنشاء التعليق خلال الساعة الأخيرة:

(request.time - resource.data.createdAt) < duration.value(1, 'h');

عند الجمع بين هذه العوامل والعامل المنطقي AND، تصبح قاعدة تعديل التعليقات هي:

allow update: if
  // is author
  request.auth.uid == resource.data.authorUID &&
  // within an hour of comment creation
  (request.time - resource.data.createdAt) < duration.value(1, 'h');

أعِد إجراء الاختبارات وتأكَّد من اجتياز اختبار آخر.

11- حذف التعليقات: التحقق من ملكية أحد الوالدين

يمكن حذف التعليقات من قِبل مؤلف التعليق أو المشرف أو مؤلف مشاركة المدونة.

أولاً، بما أنّ الوظيفة المساعدة التي أضفتها سابقًا لعمليات التحقّق من حقل authorUID يمكن أن تكون مضمّنة في مشاركة أو تعليق، يمكنك إعادة استخدام الدالة المساعدة للتحقّق ممّا إذا كان المستخدم هو المؤلف أو المشرف:

isAuthorOrModerator(resource.data, request.auth)

للتحقّق مما إذا كان المستخدم هو مؤلف مشاركة المدونة، استخدِم get للبحث عن المشاركة في Firestore:

request.auth.uid == get(/databases/$(database)/documents/published/$(postID)).data.authorUID

بما أنّ أيًا من هذه الشروط كافٍ، استخدِم عامل تشغيل OR منطقي بينها:

allow delete: if
  // is comment author or moderator
  isAuthorOrModerator(resource.data, request.auth) ||
  // is blog post author
  request.auth.uid == get(/databases/$(database)/documents/published/$(postID)).data.authorUID;

أعِد إجراء الاختبارات وتأكَّد من اجتياز اختبار آخر.

وملف القواعد بالكامل هو:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {
      let isAuthor = auth.uid == post.authorUID;
      let isModerator = auth.token.isModerator == true;
      return isAuthor || isModerator;
    }

    function titleIsUnder50Chars(post) {
      return post.title.size() < 50;
    }

    // Draft blog posts
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User is author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and createdAt fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        titleIsUnder50Chars(request.resource.data);

      allow update: if
        // User is author
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
          ]) &&
        titleIsUnder50Chars(request.resource.data);

      // Can be read or deleted by author or moderator
      allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
    }

    // Published blog posts are denormalized from drafts
    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;

      allow update: if
        isAuthorOrModerator(resource.data, request.auth) &&
        // Immutable fields are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "publishedAt",
          "url"
        ]) &&
        // Required fields are present
        request.resource.data.keys().hasAll([
          "content",
          "title",
          "visible"
        ]) &&
        titleIsUnder50Chars(request.resource.data);
    }

    match /published/{postID}/comments/{commentID} {
      // `authorUID`: string, required
      // `createdAt`: timestamp, required
      // `editedAt`: timestamp, optional
      // `comment`: string, < 500 characters, required

      // Must have permanent account to read comments
      allow read: if !(request.auth.token.firebase.sign_in_provider == "anonymous");

      allow create: if
        // User has verified email
        request.auth.token.email_verified == true &&
        // Comment is under 500 characters
        request.resource.data.comment.size() < 500 &&
        // UID is not on the block list
        !exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));

      allow update: if
        // is author
        request.auth.uid == resource.data.authorUID &&
        // within an hour of comment creation
        (request.time - resource.data.createdAt) < duration.value(1, 'h');

      allow delete: if
        // is comment author or moderator
        isAuthorOrModerator(resource.data, request.auth) ||
        // is blog post author
        request.auth.uid == get(/databases/$(database)/documents/published/$(postID)).data.authorUID;
    }
  }
}

12- الخطوات التالية

تهانينا لقد كتبت قواعد الأمان التي أدت إلى اجتياز جميع الاختبارات وتأمين التطبيق!

إليك بعض المواضيع ذات الصلة التي يمكن التعمّق فيها لاحقًا:

  • مشاركة مدونة: كيفية مراجعة قواعد الأمان لمراجعة الرموز البرمجية
  • درس تطبيقي حول الترميز: نبذة عن التطوير المحلي الأول باستخدام أدوات المحاكاة
  • فيديو: كيفية استخدام إعداد CI في الاختبارات المستندة إلى المحاكي باستخدام مهام GitHub