הגנה על נתוני 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 כדי להריץ בדיקות יחידה (unit tests) על כללי האבטחה, ולוודא שהכללים מאפשרים או חוסמים את הגישה שאתם מצפים לה.

במאמר הזה נסביר איך:

  • מתן הרשאות מפורטות
  • אכיפה של אימות נתונים ואימות סוגים
  • הטמעה של בקרת גישה מבוססת-מאפיינים
  • הענקת גישה על סמך שיטת האימות
  • יצירת פונקציות בהתאמה אישית
  • יצירת כללי אבטחה שמבוססים על זמן
  • הטמעה של רשימת דחייה ומחיקות רכות
  • הסבר על מקרים שבהם כדאי לבצע דה-נורמליזציה של נתונים כדי להתאים אותם למספר דפוסי גישה

2. הגדרה

זו אפליקציה לבלוגינג. הנה סיכום כללי של הפונקציונליות של האפליקציה:

טיוטות של פוסטים בבלוג:

  • המשתמשים יכולים ליצור טיוטות של פוסטים בבלוג, שנשמרות באוסף drafts.
  • המחבר יכול להמשיך לעדכן את הטיוטה עד שהיא מוכנה לפרסום.
  • כשהוא מוכן לפרסום, מופעלת פונקציה של Firebase שיוצרת מסמך חדש באוסף published.
  • המחבר או מנהלי האתר יכולים למחוק טיוטות

פוסטים שפורסמו בבלוג:

  • משתמשים לא יכולים ליצור פוסטים שפורסמו, אלא רק באמצעות פונקציה.
  • אפשר רק למחוק אותם באופן זמני, כלומר לעדכן את המאפיין visible לערך false.

תגובות

  • בפוסטים שפורסמו אפשר להוסיף תגובות, שהן אוסף משנה בכל פוסט שפורסם.
  • כדי לצמצם את הסיכון לשימוש לרעה, המשתמשים צריכים לאמת את כתובת האימייל שלהם ולא להיות ברשימת המשתמשים שאין להם הרשאה להגיב כדי להשאיר תגובה.
  • אפשר לעדכן תגובות רק תוך שעה אחרי הפרסום שלהן.
  • רק המגיבים, כותבי הפוסט המקורי או המנהלים יכולים למחוק תגובות.

בנוסף לכללי גישה, תיצרו כללי אבטחה שיאכפו שדות חובה ואימות נתונים.

הכל יקרה באופן מקומי, באמצעות חבילת הכלים לאמולטור של Firebase.

קבלת קוד המקור

ב-codelab הזה תתחילו עם בדיקות לכללי האבטחה, אבל עם כללי אבטחה מינימליים, ולכן הדבר הראשון שצריך לעשות הוא לשכפל את המקור כדי להריץ את הבדיקות:

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

אחר כך עוברים לספרייה initial-state, שבה תעבדו עד סוף ה-codelab:

$ cd codelab-rules/initial-state

עכשיו מתקינים את יחסי התלות כדי להפעיל את הבדיקות. אם החיבור לאינטרנט איטי, יכול להיות שהפעולה תימשך דקה או שתיים:

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

קבלת Firebase CLI

חבילת כלי האמולטור שבה תשתמשו כדי להריץ את הבדיקות היא חלק מ-Firebase CLI (ממשק שורת הפקודה), שאפשר להתקין במחשב באמצעות הפקודה הבאה:

$ npm install -g firebase-tools

לאחר מכן, מוודאים שמותקנת הגרסה העדכנית של ה-CLI. ה-codelab הזה אמור לפעול עם גרסה 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, ‏ match /{document=**}, משתמשת בתחביר ** כדי להחיל באופן רקורסיבי על כל המסמכים באוספי משנה. מכיוון שהיא ברמה העליונה, כרגע אותו כלל כללי חל על כל הבקשות, לא משנה מי שולח את הבקשה או אילו נתונים הוא מנסה לקרוא או לכתוב.

מתחילים בהסרת משפט ההתאמה הפנימי ביותר והחלפתו ב-match /drafts/{draftID}. (הערות על מבנה המסמכים יכולות לעזור בכללים, והן ייכללו ב-codelab הזה. הן תמיד אופציונליות).

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
    }
  }
}

הכלל הראשון שתכתבו לטיוטות יקבע מי יכול ליצור את המסמכים. באפליקציה הזו, רק האדם שמופיע כמחבר יכול ליצור טיוטות. בודקים שמספר ה-UID של האדם ששלח את הבקשה זהה למספר ה-UID שמופיע במסמך.

התנאי הראשון ליצירה יהיה:

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. עדכון טיוטות של פוסטים בבלוג.

בשלב הבא, כשהכותבים ישפרו את טיוטות הפוסטים בבלוג, הם יערוכו את טיוטות המסמכים. יצירת כלל לתנאים שבהם אפשר לעדכן פוסט. קודם כל, רק המחבר יכול לעדכן את הטיוטות שלו. שימו לב שפה בודקים את ה-UID שכבר כתוב,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. השלבים הבאים

כל הכבוד! כתבתם את כללי האבטחה שגרמו לכל הבדיקות לעבור, והאפליקציה מאובטחת!

הנה כמה נושאים קשורים שכדאי לעיין בהם:

  • פוסט בבלוג: איך לבצע בדיקת קוד של כללי אבטחה
  • Codelab: הדרכה מפורטת על פיתוח מקומי באמצעות אמולטורים
  • סרטון: איך מגדירים CI לבדיקות מבוססות אמולטור באמצעות פעולות GitHub