גישה מאובטחת לנתונים של משתמשים וקבוצות

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

פתרון: בקרת גישה מבוססת-תפקידים

אתם יכולים להשתמש במודל הנתונים של Cloud Firestore וגם בכללי אבטחה מותאמים אישית כדי להטמיע בקרת גישה מבוססת-תפקידים באפליקציה שלכם.

נניח שאתם בונים אפליקציה לכתיבה שיתופית שבה משתמשים יכולים ליצור 'סיפורים' ו'תגובות' עם דרישות האבטחה הבאות:

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

מבנה הנתונים

נניח שיש לאפליקציה אוסף stories שבו כל מסמך מייצג סיפור. לכל סיפור יש גם אוסף משנה comments שבו כל מסמך הוא תגובה לסיפור הזה.

כדי לעקוב אחרי תפקידי הגישה, מוסיפים roles שדה שהוא מיפוי של מזהי משתמשים לתפקידים:

/stories/{storyid}

{
  title: "A Great Story",
  content: "Once upon a time ...",
  roles: {
    alice: "owner",
    bob: "reader",
    david: "writer",
    jane: "commenter"
    // ...
  }
}

התגובות מכילות רק שני שדות, מזהה המשתמש של המחבר ותוכן כלשהו:

/stories/{storyid}/comments/{commentid}

{
  user: "alice",
  content: "I think this is a great story!"
}

כללי המשחק

אחרי שמתעדים את תפקידי המשתמשים במסד הנתונים, צריך לכתוב כללי אבטחה כדי לאמת אותם. הכללים האלה מניחים שהאפליקציה משתמשת ב-Firebase Auth, כך שהמשתנה request.auth.uid הוא מזהה המשתמש.

שלב 1: מתחילים עם קובץ כללים בסיסי, שכולל כללים ריקים לסטוריז ולתגובות:

service cloud.firestore {
   match /databases/{database}/documents {
     match /stories/{story} {
         // TODO: Story rules go here...

         match /comments/{comment} {
            // TODO: Comment rules go here...
         }
     }
   }
}

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

service cloud.firestore {
   match /databases/{database}/documents {
     match /stories/{story} {
        function isSignedIn() {
          return request.auth != null;
        }

        function getRole(rsc) {
          // Read from the "roles" map in the resource (rsc).
          return rsc.data.roles[request.auth.uid];
        }

        function isOneOfRoles(rsc, array) {
          // Determine if the user is one of an array of roles
          return isSignedIn() && (getRole(rsc) in array);
        }

        function isValidNewStory() {
          // Valid if story does not exist and the new story has the correct owner.
          return resource == null && isOneOfRoles(request.resource, ['owner']);
        }

        // Owners can read, write, and delete stories
        allow write: if isValidNewStory() || isOneOfRoles(resource, ['owner']);

         match /comments/{comment} {
            // ...
         }
     }
   }
}

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

service cloud.firestore {
   match /databases/{database}/documents {
     match /stories/{story} {
        function isSignedIn() {
          return request.auth != null;
        }

        function getRole(rsc) {
          return rsc.data.roles[request.auth.uid];
        }

        function isOneOfRoles(rsc, array) {
          return isSignedIn() && (getRole(rsc) in array);
        }

        function isValidNewStory() {
          return resource == null
            && request.resource.data.roles[request.auth.uid] == 'owner';
        }

        allow write: if isValidNewStory() || isOneOfRoles(resource, ['owner']);

        // Any role can read stories.
        allow read: if isOneOfRoles(resource, ['owner', 'writer', 'commenter', 'reader']);

        match /comments/{comment} {
          // Any role can read comments.
          allow read: if isOneOfRoles(get(/databases/$(database)/documents/stories/$(story)),
                                      ['owner', 'writer', 'commenter', 'reader']);
        }
     }
   }
}

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

service cloud.firestore {
   match /databases/{database}/documents {
     match /stories/{story} {
        function isSignedIn() {
          return request.auth != null;
        }

        function getRole(rsc) {
          return rsc.data.roles[request.auth.uid];
        }

        function isOneOfRoles(rsc, array) {
          return isSignedIn() && (getRole(rsc) in array);
        }

        function isValidNewStory() {
          return resource == null
            && request.resource.data.roles[request.auth.uid] == 'owner';
        }

        allow write: if isValidNewStory() || isOneOfRoles(resource, ['owner'])
        allow read: if isOneOfRoles(resource, ['owner', 'writer', 'commenter', 'reader']);

        match /comments/{comment} {
          allow read: if isOneOfRoles(get(/databases/$(database)/documents/stories/$(story)),
                                      ['owner', 'writer', 'commenter', 'reader']);

          // Owners, writers, and commenters can create comments. The
          // user id in the comment document must match the requesting
          // user's id.
          //
          // Note: we have to use get() here to retrieve the story
          // document so that we can check the user's role.
          allow create: if isOneOfRoles(get(/databases/$(database)/documents/stories/$(story)),
                                        ['owner', 'writer', 'commenter'])
                        && request.resource.data.user == request.auth.uid;
        }
     }
   }
}

שלב 5: נותנים לכותבים אפשרות לערוך את תוכן הסיפור, אבל לא לערוך את התפקידים בסיפור או לשנות מאפיינים אחרים של המסמך. לכן צריך לפצל את כלל write של הסטורי לכללים נפרדים ל-create, ל-update ול-delete, כי לכותבים יש אפשרות לעדכן רק סטורי:

service cloud.firestore {
   match /databases/{database}/documents {
     match /stories/{story} {
        function isSignedIn() {
          return request.auth != null;
        }

        function getRole(rsc) {
          return rsc.data.roles[request.auth.uid];
        }

        function isOneOfRoles(rsc, array) {
          return isSignedIn() && (getRole(rsc) in array);
        }

        function isValidNewStory() {
          return request.resource.data.roles[request.auth.uid] == 'owner';
        }

        function onlyContentChanged() {
          // Ensure that title and roles are unchanged and that no new
          // fields are added to the document.
          return request.resource.data.title == resource.data.title
            && request.resource.data.roles == resource.data.roles
            && request.resource.data.keys() == resource.data.keys();
        }

        // Split writing into creation, deletion, and updating. Only an
        // owner can create or delete a story but a writer can update
        // story content.
        allow create: if isValidNewStory();
        allow delete: if isOneOfRoles(resource, ['owner']);
        allow update: if isOneOfRoles(resource, ['owner'])
                      || (isOneOfRoles(resource, ['writer']) && onlyContentChanged());
        allow read: if isOneOfRoles(resource, ['owner', 'writer', 'commenter', 'reader']);

        match /comments/{comment} {
          allow read: if isOneOfRoles(get(/databases/$(database)/documents/stories/$(story)),
                                      ['owner', 'writer', 'commenter', 'reader']);
          allow create: if isOneOfRoles(get(/databases/$(database)/documents/stories/$(story)),
                                        ['owner', 'writer', 'commenter'])
                        && request.resource.data.user == request.auth.uid;
        }
     }
   }
}

מגבלות

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

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