Ochrona danych Firestore za pomocą reguł zabezpieczeń Firebase

1. Zanim zaczniesz

Cloud Firestore, Cloud Storage dla Firebase i Baza danych czasu rzeczywistego korzystają z plików konfiguracyjnych, które tworzysz, aby przyznawać uprawnienia do odczytu i zapisu. Ta konfiguracja, zwana regułami bezpieczeństwa, może też pełnić funkcję schematu aplikacji. Jest to jeden z najważniejszych elementów tworzenia aplikacji. W tym ćwiczeniu przeprowadzimy Cię przez cały proces.

Wymagania wstępne

  • prosty edytor, np. Visual Studio Code, Atom lub Sublime Text;
  • Node.js w wersji 8.6.0 lub nowszej (aby zainstalować Node.js, użyj nvm; aby sprawdzić wersję, uruchom node --version)
  • Java 7 lub nowsza (aby zainstalować Javę, postępuj zgodnie z tymi instrukcjami; aby sprawdzić wersję, uruchom java -version)

Co musisz zrobić

W tym ćwiczeniu z programowania zabezpieczysz prostą platformę blogową opartą na Firestore. Użyjesz emulatora Firestore do przeprowadzania testów jednostkowych reguł zabezpieczeń i sprawdzania, czy reguły zezwalają na dostęp w oczekiwany sposób.

Poznasz takie zagadnienia jak:

  • Przyznawanie szczegółowych uprawnień
  • Wymuszanie sprawdzania poprawności danych i typów
  • Implementacja kontroli dostępu opartej na atrybutach
  • Przyznawanie dostępu na podstawie metody uwierzytelniania
  • Tworzenie funkcji niestandardowych
  • Tworzenie reguł zabezpieczeń opartych na czasie
  • Wdrażanie listy odrzuconych i przenoszenia do kosza
  • Dowiedz się, kiedy denormalizować dane, aby spełnić wiele wzorców dostępu

2. Skonfiguruj

Jest to aplikacja do blogowania. Oto ogólne podsumowanie funkcji aplikacji:

Tworzenie wersji roboczych postów na blogu:

  • Użytkownicy mogą tworzyć wersje robocze postów na blogu, które są przechowywane w kolekcji drafts.
  • Autor może aktualizować wersję roboczą, dopóki nie będzie gotowa do opublikowania.
  • Gdy będzie gotowy do opublikowania, zostanie wywołana funkcja Firebase, która utworzy nowy dokument w kolekcji published.
  • Wersje robocze mogą być usuwane przez autora lub moderatorów witryny.

Opublikowane posty na blogu:

  • Opublikowanych postów nie mogą tworzyć użytkownicy, tylko funkcja.
  • Można je tylko przenieść do kosza, co powoduje zmianę wartości atrybutu visible na false.

Komentarze

  • Opublikowane posty umożliwiają dodawanie komentarzy, które są podzbiorem każdego opublikowanego posta.
  • Aby ograniczyć nadużycia, użytkownicy muszą mieć zweryfikowany adres e-mail i nie mogą znajdować się na liście osób, które nie zgadzają się z ustaleniami naukowymi.
  • Komentarze można aktualizować tylko w ciągu godziny od ich opublikowania.
  • Komentarze mogą usuwać ich autorzy, autorzy oryginalnych postów lub moderatorzy.

Oprócz reguł dostępu utworzysz reguły zabezpieczeń, które wymuszają wymagane pola i weryfikację danych.

Wszystko będzie się odbywać lokalnie przy użyciu Pakietu emulatorów Firebase.

Pobieranie kodu źródłowego

W tym laboratorium kodu zaczniesz od testów reguł bezpieczeństwa, ale samych reguł bezpieczeństwa będzie niewiele, więc pierwszą rzeczą, którą musisz zrobić, jest sklonowanie źródła, aby uruchomić testy:

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

Następnie przejdź do katalogu initial-state, w którym będziesz pracować przez resztę tego laboratorium:

$ cd codelab-rules/initial-state

Teraz zainstaluj zależności, aby móc uruchamiać testy. Jeśli korzystasz z wolniejszego połączenia internetowego, może to potrwać minutę lub dwie:

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

Pobieranie wiersza poleceń Firebase

Pakiet Emulator Suite, którego użyjesz do uruchomienia testów, jest częścią wiersza poleceń Firebase, który możesz zainstalować na swoim komputerze za pomocą tego polecenia:

$ npm install -g firebase-tools

Następnie sprawdź, czy masz najnowszą wersję interfejsu CLI. Te warsztaty powinny działać w wersji 8.4.0 lub nowszej, ale nowsze wersje zawierają więcej poprawek błędów.

$ firebase --version
9.10.2

3. Przeprowadzanie testów

W tej sekcji przeprowadzisz testy lokalnie. Oznacza to, że nadszedł czas na uruchomienie pakietu emulatorów.

Uruchamianie emulatorów

Aplikacja, z którą będziesz pracować, ma 3 główne kolekcje Firestore: drafts zawiera posty na blogu, które są w trakcie tworzenia, kolekcja published zawiera opublikowane posty na blogu, a comments to podkolekcja opublikowanych postów. Repozytorium zawiera testy jednostkowe reguł bezpieczeństwa, które określają atrybuty użytkownika i inne warunki wymagane, aby użytkownik mógł tworzyć, odczytywać, aktualizować i usuwać dokumenty w kolekcjach drafts, publishedcomments. Aby testy zakończyły się powodzeniem, musisz napisać reguły zabezpieczeń.

Na początku baza danych jest zablokowana: odczyty i zapisy w bazie danych są powszechnie odrzucane, a wszystkie testy kończą się niepowodzeniem. Podczas pisania reguł zabezpieczeń testy będą przechodzić. Aby zobaczyć testy, otwórz functions/test.js w edytorze.

W wierszu poleceń uruchom emulatory za pomocą polecenia emulators:exec i przeprowadź testy:

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

Przewiń na początek danych wyjściowych:

$ 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

...

Obecnie występuje 9 błędów. W miarę tworzenia pliku reguł możesz mierzyć postępy, obserwując, jak przechodzą kolejne testy.

4. tworzyć wersje robocze postów na blogu,

Dostęp do wersji roboczych postów na blogu różni się od dostępu do opublikowanych postów, dlatego ta aplikacja do blogowania przechowuje wersje robocze w osobnej kolekcji – /drafts. Do wersji roboczych ma dostęp tylko autor lub moderator. Zawierają one weryfikację wymaganych i niezmiennych pól.

Po otwarciu pliku firestore.rules znajdziesz domyślny plik reguł:

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

Instrukcja dopasowania match /{document=**} używa składni **, aby rekursywnie zastosować ją do wszystkich dokumentów w kolekcjach podrzędnych. Ponieważ znajduje się ona na najwyższym poziomie, obecnie ta sama ogólna reguła ma zastosowanie do wszystkich żądań, niezależnie od tego, kto je wysyła i jakie dane próbuje odczytać lub zapisać.

Zacznij od usunięcia najbardziej wewnętrznego warunku dopasowania i zastąpienia go symbolem match /drafts/{draftID}. (Komentarze dotyczące struktury dokumentów mogą być przydatne w przypadku reguł i zostaną uwzględnione w tym samouczku. Są one zawsze opcjonalne).

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

Pierwsza reguła, którą napiszesz dla wersji roboczych, będzie określać, kto może tworzyć dokumenty. W tej aplikacji wersje robocze może tworzyć tylko osoba wymieniona jako autor. Sprawdź, czy identyfikator UID osoby przesyłającej prośbę jest taki sam jak identyfikator UID podany w dokumencie.

Pierwszy warunek utworzenia będzie następujący:

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

Dokumenty można tworzyć tylko wtedy, gdy zawierają 3 wymagane pola: authorUID, createdAttitle. (Użytkownik nie podaje wartości pola createdAt. Wymaga to, aby aplikacja dodała je przed próbą utworzenia dokumentu). Wystarczy sprawdzić, czy atrybuty są tworzone, więc możesz sprawdzić, czy request.resource zawiera wszystkie te klucze:

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

Ostatnim wymaganiem dotyczącym tworzenia posta na blogu jest to, że tytuł nie może mieć więcej niż 50 znaków:

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

Ponieważ wszystkie te warunki muszą być spełnione, połącz je operatorem logicznym AND, &&. Pierwsza reguła będzie wyglądać tak:

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

W terminalu ponownie uruchom testy i sprawdź, czy pierwszy test został zaliczony.

5. Aktualizuj wersje robocze postów na blogu.

Następnie autorzy będą edytować wersje robocze postów na blogu. Utwórz regułę określającą warunki, w których można zaktualizować post. Po pierwsze, tylko autor może aktualizować swoje wersje robocze. Zwróć uwagę, że sprawdzasz tutaj UID, który jest już zapisanyresource.data.authorUID:

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

Drugim wymaganiem dotyczącym aktualizacji jest to, że 2 atrybuty, authorUIDcreatedAt, nie powinny się zmieniać:

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

Tytuł powinien mieć maksymalnie 50 znaków:

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

Ponieważ wszystkie te warunki muszą być spełnione, połącz je za pomocą funkcji &&:

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;

Pełne reguły będą wyglądać tak:

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

Ponownie uruchom testy i sprawdź, czy inny test zakończy się powodzeniem.

6. Usuwanie i odczytywanie wersji roboczych: kontrola dostępu oparta na atrybutach

Autorzy mogą nie tylko tworzyć i aktualizować wersje robocze, ale też je usuwać.

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

Dodatkowo autorzy, których token uwierzytelniający ma atrybut isModerator, mogą usuwać wersje robocze:

request.auth.token.isModerator == true

Ponieważ do usunięcia wystarczy spełnienie jednego z tych warunków, połącz je za pomocą operatora logicznego LUB ||:

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

Te same warunki dotyczą odczytów, więc uprawnienia można dodać do reguły:

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

Pełne zasady to:

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

Ponownie uruchom testy i sprawdź, czy inny test został zaliczony.

7. Odczytywanie, tworzenie i usuwanie opublikowanych postów: denormalizacja dla różnych wzorców dostępu

Wzorce dostępu do opublikowanych i roboczych postów są tak różne, że ta aplikacja denormalizuje posty w oddzielnych kolekcjach draftpublished. Na przykład opublikowane posty może przeczytać każdy, ale nie można ich trwale usunąć, a wersje robocze można usunąć, ale mogą je przeczytać tylko autor i moderatorzy. W tej aplikacji, gdy użytkownik chce opublikować wersję roboczą posta na blogu, wywoływana jest funkcja, która tworzy nowy opublikowany post.

Następnie napisz reguły dotyczące opublikowanych postów. Najprostsze reguły do napisania to te, które mówią, że opublikowane posty może czytać każdy, ale nikt nie może ich tworzyć ani usuwać. Dodaj te reguły:

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

Po dodaniu tych reguł do istniejących reguł cały plik reguł będzie wyglądać tak:

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

Ponownie uruchom testy i sprawdź, czy inny test zakończy się powodzeniem.

8. Aktualizowanie opublikowanych postów: funkcje niestandardowe i zmienne lokalne

Warunki aktualizacji opublikowanego posta:

  • może to zrobić tylko autor lub moderator,
  • musi zawierać wszystkie wymagane pola.

Warunki dotyczące bycia autorem lub moderatorem możesz skopiować i wkleić, ale z czasem może to utrudnić czytanie i utrzymywanie porządku. Zamiast tego utworzysz funkcję niestandardową, która będzie zawierać logikę dotyczącą bycia autorem lub moderatorem. Następnie wywołasz ją z kilku warunków.

Tworzenie funkcji niestandardowej

Nad instrukcją dopasowania do wersji roboczych utwórz nową funkcję o nazwie isAuthorOrModerator, która przyjmuje jako argumenty dokument posta (będzie działać w przypadku wersji roboczych i opublikowanych postów) oraz obiekt uwierzytelniania użytkownika:

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

Używanie zmiennych lokalnych

W funkcji użyj słowa kluczowego let, aby ustawić zmienne isAuthorisModerator. Wszystkie funkcje muszą kończyć się instrukcją return, a nasza funkcja będzie zwracać wartość logiczną wskazującą, czy któraś ze zmiennych ma wartość true:

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

Wywoływanie funkcji

Teraz zaktualizuj regułę dotyczącą wersji roboczych, aby wywoływała tę funkcję. Pamiętaj, aby jako pierwszy argument przekazać wartość resource.data:

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

Teraz możesz napisać warunek aktualizacji opublikowanych postów, który korzysta też z nowej funkcji:

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

Dodawanie weryfikacji

Niektórych pól opublikowanego posta nie należy zmieniać. Dotyczy to w szczególności pól url, authorUIDpublishedAt, które są niezmienne. Pozostałe 2 pola, titlecontent, oraz visible muszą być nadal obecne po aktualizacji. Dodaj warunki, aby wymusić te wymagania w przypadku aktualizacji opublikowanych postów:

// 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"
])

Tworzenie własnej funkcji niestandardowej

Na koniec dodaj warunek, że tytuł musi mieć mniej niż 50 znaków. Ponieważ jest to ponownie używana logika, możesz to zrobić, tworząc nową funkcję titleIsUnder50Chars. Dzięki nowej funkcji warunek aktualizacji opublikowanego posta będzie wyglądać tak:

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);

Pełna wersja pliku reguł wygląda tak:

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

Ponownie uruchom testy. W tym momencie powinno być 5 testów zakończonych powodzeniem i 4 niepowodzeniem.

9. Komentarze: podzbiory i uprawnienia dostawcy logowania

Opublikowane posty umożliwiają dodawanie komentarzy, które są przechowywane w podkolekcji opublikowanego posta (/published/{postID}/comments/{commentID}). Domyślnie reguły kolekcji nie mają zastosowania do podkolekcji. Nie chcesz, aby te same reguły, które mają zastosowanie do dokumentu nadrzędnego opublikowanego posta, obowiązywały w przypadku komentarzy. Utworzysz inne reguły.

Aby napisać reguły dostępu do komentarzy, zacznij od instrukcji dopasowania:

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

Czytanie komentarzy: nie może być anonimowe

W przypadku tej aplikacji komentarze mogą czytać tylko użytkownicy, którzy utworzyli stałe konto, a nie konto anonimowe. Aby wymusić zastosowanie tej reguły, wyszukaj atrybut sign_in_provider, który znajduje się w każdym obiekcie auth.token:

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

Ponownie uruchom testy i sprawdź, czy jeden z nich został zaliczony.

Tworzenie komentarzy: sprawdzanie listy odrzuceń

Komentarz można utworzyć pod 3 warunkami:

  • użytkownik musi mieć zweryfikowany adres e-mail;
  • komentarz musi mieć mniej niż 500 znaków,
  • nie mogą znajdować się na liście zablokowanych użytkowników, która jest przechowywana w Firestore w kolekcji bannedUsers. Omówmy te warunki po kolei:
request.auth.token.email_verified == true
request.resource.data.comment.size() < 500
!exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));

Ostatnia zasada tworzenia komentarzy to:

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));

Cały plik reguł wygląda teraz tak:

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

Przeprowadź testy ponownie i upewnij się, że jeszcze jeden z nich zakończy się powodzeniem.

10. Aktualizowanie komentarzy: reguły czasowe

Logika biznesowa komentarzy polega na tym, że autor może je edytować przez godzinę od utworzenia. Aby to zrobić, użyj createdAtsygnatury czasowej.

Najpierw musisz potwierdzić, że użytkownik jest autorem:

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

Następnie sprawdź, czy komentarz został utworzony w ciągu ostatniej godziny:

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

Łącząc je za pomocą operatora logicznego AND, reguła aktualizowania komentarzy przyjmuje postać:

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');

Przeprowadź testy ponownie i upewnij się, że jeszcze jeden z nich zakończy się powodzeniem.

11. Usuwanie komentarzy: sprawdzanie własności elementu nadrzędnego

Komentarze mogą usuwać ich autorzy, moderatorzy lub autorzy postów na blogu.

Po pierwsze, dodana wcześniej funkcja pomocnicza sprawdza pole authorUID, które może występować w poście lub komentarzu. Możesz więc ponownie użyć tej funkcji, aby sprawdzić, czy użytkownik jest autorem lub moderatorem:

isAuthorOrModerator(resource.data, request.auth)

Aby sprawdzić, czy użytkownik jest autorem posta na blogu, użyj get, aby wyszukać post w Firestore:

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

Ponieważ wystarczy spełnienie dowolnego z tych warunków, użyj między nimi operatora logicznego LUB:

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;

Przeprowadź testy ponownie i upewnij się, że jeszcze jeden z nich zakończy się powodzeniem.

Cały plik z regułami wygląda tak:

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. Dalsze kroki

Gratulacje! Napisano reguły zabezpieczeń, które przeszły wszystkie testy i zabezpieczyły aplikację.

Oto kilka powiązanych tematów, które warto poznać:

  • Post na blogu: jak sprawdzać kod reguł zabezpieczeń
  • Ćwiczenia w Codelabs: lokalne tworzenie aplikacji z użyciem emulatorów
  • Film: jak skonfigurować CI na potrzeby testów opartych na emulatorze za pomocą działań na GitHubie