הגנה על נתוני Firestore באמצעות כללי האבטחה של Firebase

1. לפני שמתחילים

כדי להעניק גישה לקריאה ולכתיבה, Cloud Firestore,‏ Cloud Storage for Firebase ומסד הנתונים בזמן אמת מסתמכים על קובצי תצורה שאתם כותבים. ההגדרה הזו, שנקראת 'כללי אבטחה', יכולה לשמש גם כסוג של סכימה לאפליקציה. זהו אחד החלקים החשובים ביותר בפיתוח האפליקציה. ב-Codelab הזה נסביר איך עושים את זה.

דרישות מוקדמות

  • כלי עריכה פשוט כמו Visual Studio Code, Atom או Sublime Text
  • Node.js מגרסה 8.6.0 ואילך (כדי להתקין את Node.js, יש להשתמש ב-nvm. כדי לבדוק את הגרסה שלכם, מריצים את node --version)
  • Java 7 ואילך (כדי להתקין את Java, פועלים לפי ההוראות האלה. כדי לבדוק את הגרסה, מריצים את הפקודה java -version)

מה עליכם לעשות

ב-codelab הזה תלמדו לאבטח פלטפורמת בלוג פשוטה שנבנתה על Firestore. תשתמשו במהנת Firestore כדי להריץ בדיקות יחידה (unit testing) של כללי האבטחה, ולוודא שהכללים מאפשרים ומונעים את הגישה שאתם מצפים לה.

תלמדו איך:

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

2. הגדרה

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

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

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

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

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

תגובות

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

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

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

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

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

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

לאחר מכן עוברים לספריית המצב הראשוני, ועובדים בה את שאר הזמן של ה-Codelab הזה:

$ cd codelab-rules/initial-state

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

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

הורדת ה-CLI של Firebase

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

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 Actions