Ochrona danych Firestore za pomocą reguł zabezpieczeń Firebase

1. Zanim zaczniesz

Cloud Firestore, Cloud Storage dla Firebase i Baza danych czasu rzeczywistego zależą od napisanych przez Ciebie plików konfiguracji w celu przyznawania uprawnień do odczytu i zapisu. Ta konfiguracja, nazywana regułami zabezpieczeń, może też stanowić rodzaj schematu dla Twojej aplikacji. To jedna z najważniejszych części tworzenia aplikacji. Dzięki temu ćwiczeniu w Codelabs dowiesz się, jak to zrobić.

Wymagania wstępne

  • Prosty edytor, taki jak 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ę, wykonaj te instrukcje; aby sprawdzić wersję, uruchom java -version)

Co trzeba zrobić

Z tego ćwiczenia w Codelabs dowiesz się, jak stworzyć prostą platformę bloga opartą na Firestore. Za pomocą emulatora Firestore będziesz przeprowadzać testy jednostkowe pod kątem reguł zabezpieczeń i sprawdzać, czy reguły zezwalają na oczekiwany dostęp lub go blokują.

Poznasz takie zagadnienia jak:

  • Przyznaj szczegółowe uprawnienia
  • Wymuszanie weryfikacji danych i typu
  • Wdróż kontrolę dostępu opartą 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

To jest aplikacja do blogowania. Oto ogólne podsumowanie funkcji aplikacji:

Wersje robocze postów na blogu:

  • Użytkownicy mogą tworzyć wersje robocze postów na blogu, które znajdują się w kolekcji drafts.
  • Autor może aktualizować wersję roboczą, dopóki nie będzie gotowa do opublikowania.
  • Gdy dokument jest gotowy do opublikowania, uruchamiana jest funkcja Firebase, która tworzy nowy dokument w kolekcji published.
  • Wersje robocze może usuwać autor lub moderatorzy witryny

Opublikowane posty na blogu:

  • Opublikowane posty nie mogą tworzyć użytkownicy – mogą to robić tylko za pomocą funkcji.
  • Można je tylko przenieść do kosza, co powoduje, że atrybut visible ma wartość false (fałsz).

Komentarze

  • W opublikowanych postach można dodawać komentarze stanowiące podkolekcję dotyczącą każdego opublikowanego posta.
  • Aby zmniejszyć liczbę nadużyć, użytkownicy muszą mieć zweryfikowany adres e-mail i nie mogą znajdować się na liście odrzuconych, aby móc dodawać komentarze.
  • Komentarze można zaktualizować tylko w ciągu godziny od ich opublikowania.
  • Komentarze mogą usuwać ich autor, autor oryginalnego posta lub moderatorzy.

Oprócz reguł dostępu utworzysz też reguły zabezpieczeń, które wymuszają stosowanie wymaganych pól i sprawdzanie poprawności danych.

Wszystko będzie następowało lokalnie za pomocą Pakietu emulatorów Firebase.

Pobieranie kodu źródłowego

W ramach tego ćwiczenia w programie zaczniesz od testowania reguł zabezpieczeń, ale najpierw użyjesz powielanych reguł zabezpieczeń, więc najpierw musisz skopiować źródło, aby je przeprowadzić:

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

Następnie przejdź do katalogu ze stanem początkowym, w którym będziesz pracować do końca tego ćwiczenia w Codelabs:

$ cd codelab-rules/initial-state

Teraz zainstaluj zależności, aby uruchomić testy. Jeśli masz wolniejsze połączenie, może to potrwać kilka minut:

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

Pobieranie interfejsu wiersza poleceń Firebase

Pakiet emulatorów służących do przeprowadzania testów jest częścią interfejsu wiersza poleceń Firebase, który można zainstalować na komputerze za pomocą tego polecenia:

$ npm install -g firebase-tools

Następnie sprawdź, czy masz najnowszą wersję interfejsu wiersza poleceń. To ćwiczenie w Codelabs powinno 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 pora uruchomić Pakiet emulatorów.

Uruchamianie emulatorów

Aplikacja, z którą będziesz pracować, ma 3 główne kolekcje Firestore: drafts zawiera trwające posty na blogu, kolekcja published zawiera opublikowane posty, a comments to podkolekcja, która zawiera opublikowane posty. Repozytorium zawiera testy jednostkowe reguł zabezpieczeń, które definiują atrybuty użytkownika i inne warunki wymagane do tworzenia, odczytywania, aktualizowania i usuwania dokumentów w kolekcjach drafts, published i comments. Będziesz też tworzyć reguły zabezpieczeń, które pozwolą na zaliczenie tych testów.

Na początek baza danych jest zablokowana: odczyty i zapisy w bazie danych są odrzucane, a wszystkie testy kończą się niepowodzeniem. Testy zakończą się powodzeniem, gdy będziesz pisać reguły zabezpieczeń. Aby zobaczyć testy, otwórz w edytorze functions/test.js.

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ń do góry 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 jest 9 niepowodzeń. W miarę tworzenia pliku reguł możesz monitorować postępy, śledząc więcej zaliczonych testów.

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

Ponieważ dostęp do wersji roboczych postów na blogu tak bardzo różni się od dostępu do opublikowanych postów, ta aplikacja do tworzenia blogów przechowuje wersje robocze postów w osobnej kolekcji /drafts. Dostęp do wersji roboczych może uzyskać tylko autor lub moderator. Weryfikacja obejmuje pola wymagane i stałe.

Otwórz plik firestore.rules. Znajdziesz w nim plik reguł domyślnych:

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 rekurencyjnie stosować ją do wszystkich dokumentów w kolekcjach podrzędnych. A ponieważ jest to najwyższy poziom, obecnie ta sama reguła ogólna ma zastosowanie do wszystkich żądań, niezależnie od tego, kto je zgłasza, i jakie dane próbuje odczytać lub zapisać.

Zacznij od usunięcia najbardziej wewnętrznej instrukcji dopasowania i zastąp ją ciągiem match /drafts/{draftID}. Komentarze dotyczące struktury dokumentów mogą być pomocne przy tworzeniu reguł. Zostaną one ujęte w tym ćwiczeniu z programowania. Są 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ą utworzysz dla wersji roboczych, decyduje o tym, kto może tworzyć dokumenty. W tej aplikacji wersje robocze może tworzyć tylko osoba podana jako autor. Sprawdź, czy identyfikator UID osoby przesyłającej żądanie jest taki sam jak ten wymieniony w dokumencie.

Pierwszym warunkiem utworzenia będzie:

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

Następnie dokumenty można tworzyć tylko wtedy, gdy zawierają 3 wymagane pola: authorUID,createdAt i title. (Użytkownik nie podał pola createdAt; wymusza to dodanie go, zanim aplikacja będzie chciała utworzyć dokument). Wystarczy sprawdzić, czy atrybuty są tworzone, więc możesz sprawdzić, czy request.resource ma wszystkie te klucze:

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

Ostatnim wymogiem 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 za pomocą operatora logicznego AND (I) &&. Pierwsza reguła stanie się:

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

Uruchom testy w terminalu ponownie i sprawdź, czy pierwszy test się zaliczony.

5. Zaktualizuj wersje robocze postów na blogu.

Następnie, w miarę jak autorzy dopracowują wersje robocze postów na blogu, zmieniają wersje robocze dokumentów. Utwórz regułę określającą warunki aktualizacji posta. Po pierwsze, tylko autor może aktualizować wersje robocze. Zwróć uwagę,że sprawdzasz zapisany już identyfikator UID:resource.data.authorUID:

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

Drugim wymaganiem aktualizacji jest to, aby 2 atrybuty (authorUID i createdAt) nie powinny się zmieniać:

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

Tytuł może 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 wyglądają 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;
    }
  }
}

Uruchom testy ponownie i potwierdź, czy inny test zaliczył się.

6. Usuwanie i odczytywanie wersji roboczych: kontrola dostępu na podstawie atrybutów

Autorzy mogą tworzyć i aktualizować wersje robocze oraz usuwać wersje robocze.

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

Oprócz tego autorzy z atrybutem isModerator w tokenie uwierzytelniania mogą usuwać wersje robocze:

request.auth.token.isModerator == true

Ponieważ do usunięcia wystarczy jeden z tych warunków, połącz go za pomocą operatora logicznego LUB (||):

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

Do odczytów obowiązują te same warunki, więc można dodać do reguły te uprawnienia:

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

Pełne reguły są teraz:

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

Uruchom testy ponownie i upewnij się, że inny test zakończył się powodzeniem.

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

Wzorce dostępu do opublikowanych postów i wersji roboczych postów są tak różne, dlatego ta aplikacja denormalizuje posty w osobne kolekcje draft i published. Na przykład opublikowane posty mogą czytać wszyscy, ale nie można ich trwale usunąć. Natomiast wersje robocze można usunąć, ale mogą je przeczytać tylko autor i moderatorzy. Gdy użytkownik będzie chciał opublikować w tej aplikacji wersję roboczą posta na blogu, zostanie uruchomiona funkcja, która utworzy nowego opublikowanego posta.

Następnie napisz reguły dla opublikowanych postów. Najprostsze zasady do napisania są takie, że opublikowane posty może przeczytać każdy i 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 ich do istniejących reguł cały plik reguł stanie się taki:

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

Uruchom testy ponownie i potwierdź, że inny test zaliczył się.

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.

Skoro masz już warunki dla bycia autorem lub moderatorem, możesz je skopiować i wkleić, ale z czasem może to stać się trudne do odczytania i utrzymania. Zamiast tego utworzysz funkcję niestandardową zawierającą logikę pełnienia roli autora lub moderatora. Następnie wywołasz ją na podstawie kilku warunków.

Tworzenie funkcji niestandardowej

Nad deklaracjami dopasowania wersji roboczych utwórz nową funkcję o nazwie isAuthorOrModerator, która przyjmuje jako argumenty dokument post (będzie działać w przypadku wersji roboczych lub opublikowanych postów) i 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

Użyj słowa kluczowego let w funkcji, aby ustawić zmienne isAuthor i isModerator. Wszystkie funkcje muszą kończyć się instrukcją zwrotną, a nasza zwraca wartość logiczną wskazującą, czy któraś ze zmiennych jest prawda:

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

Wywołaj funkcję

Teraz zaktualizujesz regułę dla wersji roboczych, aby wywoływać tę funkcję, zachowując ostrożność przy przekazywaniu funkcji resource.data jako pierwszego argumentu:

  // 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 również korzysta z nowej funkcji:

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

Dodaj weryfikacje

Niektórych pól opublikowanego posta nie należy zmieniać. W szczególności pola url, authorUID i publishedAt są stałe. Pozostałe 2 pola (title i content oraz visible) muszą być wciąż dostępne po aktualizacji. Dodaj warunki, aby egzekwować 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"
])

Samodzielne tworzenie funkcji niestandardowej

Na koniec dodaj warunek, w którym tytuł musi mieć mniej niż 50 znaków. Jest to logika ponownie wykorzystana, więc możesz to zrobić, tworząc nową funkcję titleIsUnder50Chars. Po dodaniu nowej funkcji warunek aktualizacji opublikowanego posta wygląda następująco:

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łny plik reguł to:

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. Na tym etapie powinno być 5 testów zdanych i 4 niezdanych.

9. Komentarze: kolekcje podrzędne i uprawnienia dostawcy logowania

Do opublikowanych postów można dodawać komentarze, 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 do komentarzy były stosowane te same reguły, które obowiązują w dokumencie nadrzędnym opublikowanego posta; będziesz tworzyć różne.

Aby stworzyć reguły dostępu do komentarzy, zacznij od wyrażenia dopasowania:

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

Czytanie komentarzy: nie można zachować anonimowości

W tej aplikacji komentarze mogą czytać tylko użytkownicy, którzy utworzyli stałe konto, a nie konto anonimowe. Aby egzekwować tę regułę, odszukaj atrybut sign_in_provider w każdym obiekcie auth.token:

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

Ponownie uruchom testy i potwierdź, że został zaliczony.

Tworzenie komentarzy: sprawdzanie listy odrzuconych

Aby utworzyć komentarz, musisz spełnić 3 warunki:

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

Ostateczna zasada tworzenia komentarzy:

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 z regułami 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));
    }
  }
}

Powtórz testy i upewnij się, że każdy z nich został zaliczony.

10. Aktualizowanie komentarzy: reguły zależne od czasu

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

Aby ustalić, że użytkownik jest autorem:

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

Komentarz został utworzony w ciągu ostatniej godziny:

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

Po połączeniu ich z operatorem logicznym „I” reguła aktualizowania komentarzy będzie wyglądać tak:

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

Powtórz testy i upewnij się, że każdy z nich został zaliczony.

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

Komentarze mogą usuwać ich autor, moderator i autor posta na blogu.

Po pierwsze, ponieważ dodana wcześniej funkcja pomocnicza sprawdza pole authorUID, które może istnieć w poście lub komentarzu, możesz ponownie użyć funkcji pomocniczej, aby sprawdzić, czy użytkownik jest autorem czy moderatorem:

isAuthorOrModerator(resource.data, request.auth)

Aby sprawdzić, czy użytkownik jest autorem posta na blogu, wyszukaj go w Firestore przy użyciu get:

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

Ponieważ każdy z tych warunków jest wystarczający, zastosuj między nimi operator logiczny 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;

Powtórz testy i upewnij się, że każdy z nich został zaliczony.

A cały plik z regułami to:

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! Udało Ci się napisać reguły zabezpieczeń, które spełniły wszystkie testy i zabezpieczyły aplikację.

Oto kilka powiązanych tematów:

  • Post na blogu: Jak zweryfikować reguły zabezpieczeń za pomocą kodu
  • Ćwiczenia z programowania: przechodzenie przez pierwsze lokalne programowanie z użyciem emulatorów.
  • Film: jak skonfigurować CI na potrzeby testów w emulatorze za pomocą działań GitHub