Firebase 보안 규칙으로 Firestore 데이터를 보호하세요

1. 시작하기 전에

Cloud Firestore, Firebase용 Cloud Storage, 실시간 데이터베이스는 사용자가 작성하는 구성 파일을 사용하여 읽기 및 쓰기 액세스 권한을 부여합니다. 보안 규칙이라고 하는 해당 구성은 앱에 대한 일종의 스키마 역할을 할 수도 있습니다. 이는 애플리케이션 개발에서 가장 중요한 부분 중 하나입니다. 이 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 에뮬레이터를 사용하여 보안 규칙에 대해 단위 테스트를 실행하고 규칙이 예상하는 액세스를 허용하거나 허용하지 않는지 확인합니다.

다음 방법을 배우게 됩니다.

  • 세분화된 권한 부여
  • 데이터 및 유형 유효성 검사 시행
  • 속성 기반 액세스 제어 구현
  • 인증 방법에 따라 액세스 권한 부여
  • 사용자 정의 함수 만들기
  • 시간 기반 보안 규칙 생성
  • 거부 목록 및 일시 삭제 구현
  • 여러 액세스 패턴을 충족하기 위해 데이터를 비정규화해야 하는 시기 이해

2. 설정

이것은 블로그 응용 프로그램입니다. 다음은 애플리케이션 기능에 대한 높은 수준의 요약입니다.

임시 블로그 게시물:

  • 사용자는 drafts 컬렉션에 있는 초안 블로그 게시물을 만들 수 있습니다.
  • 작성자는 게시 준비가 될 때까지 초안을 계속 업데이트할 수 있습니다.
  • 게시할 준비가 되면 published 컬렉션에 새 문서를 생성하는 Firebase 함수가 트리거됩니다.
  • 초안은 작성자나 사이트 중재자가 삭제할 수 있습니다.

게시된 블로그 게시물:

  • 게시된 게시물은 사용자가 생성할 수 없으며 기능을 통해서만 생성됩니다.
  • visible 속성을 false로 업데이트하는 일시 삭제만 가능합니다.

코멘트

  • 게시된 게시물에는 게시된 각 게시물의 하위 컬렉션인 댓글이 허용됩니다.
  • 남용을 줄이려면 사용자는 확인된 이메일 주소가 있어야 하며 댓글을 남기려면 거부주의자가 아니어야 합니다.
  • 댓글은 게시 후 1시간 이내에만 업데이트할 수 있습니다.
  • 댓글 작성자, 원본 게시물 작성자 또는 중재자가 댓글을 삭제할 수 있습니다.

액세스 규칙 외에도 필수 필드와 데이터 유효성 검사를 시행하는 보안 규칙을 생성하게 됩니다.

Firebase Emulator Suite를 사용하면 모든 것이 로컬에서 발생합니다.

소스 코드 받기

이 Codelab에서는 보안 규칙에 대한 테스트부터 시작하지만 보안 규칙 자체는 최소화하므로 가장 먼저 해야 할 일은 소스를 복제하여 테스트를 실행하는 것입니다.

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

그런 다음 이 Codelab의 나머지 부분을 작업할 초기 상태 디렉터리로 이동합니다.

$ cd codelab-rules/initial-state

이제 테스트를 실행할 수 있도록 종속성을 설치합니다. 인터넷 연결 속도가 느린 경우 1~2분 정도 걸릴 수 있습니다.

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

Firebase CLI 가져오기

테스트를 실행하는 데 사용할 에뮬레이터 도구 모음은 다음 명령을 사용하여 컴퓨터에 설치할 수 있는 Firebase CLI(명령줄 인터페이스)의 일부입니다.

$ npm install -g firebase-tools

다음으로 최신 버전의 CLI가 있는지 확인하세요. 이 Codelab은 버전 8.4.0 이상에서 작동하지만 이후 버전에는 더 많은 버그 수정이 포함되어 있습니다.

$ firebase --version
9.10.2

3. 테스트 실행

이 섹션에서는 로컬에서 테스트를 실행합니다. 이는 Emulator Suite를 부팅할 시간이라는 의미입니다.

에뮬레이터 시작

작업할 애플리케이션에는 세 가지 기본 Firestore 컬렉션이 있습니다. drafts 진행 중인 블로그 게시물이 포함되고, published 컬렉션에는 게시된 블로그 게시물이 포함되며, comments 은 게시된 게시물의 하위 컬렉션입니다. 리포지토리에는 사용자가 drafts , 게시 및 comments 컬렉션에서 문서를 생성, 읽기, published 및 삭제하는 데 필요한 사용자 속성 및 기타 조건을 정의하는 보안 규칙에 대한 단위 테스트가 함께 제공됩니다. 이러한 테스트를 통과할 수 있도록 보안 규칙을 작성하게 됩니다.

시작하려면 데이터베이스가 잠겨 있습니다. 데이터베이스에 대한 읽기 및 쓰기가 전체적으로 거부되고 모든 테스트가 실패합니다. 보안 규칙을 작성하면 테스트가 통과됩니다. 테스트를 보려면 편집기에서 functions/test.js 엽니다.

명령줄에서 emulators:exec 사용하여 에뮬레이터를 시작하고 테스트를 실행합니다.

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

출력 상단으로 스크롤합니다.

$ firebase emulators:exec --project=codelab --import=.seed "cd functions; npm test"
i  emulators: Starting emulators: functions, firestore, hosting
⚠  functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: auth, database, pubsub
⚠  functions: Unable to fetch project Admin SDK configuration, Admin SDK behavior in Cloud Functions emulator may be incorrect.
i  firestore: Importing data from /Users/user/src/firebase/rules-codelab/initial-state/.seed/firestore_export/firestore_export.overall_export_metadata
i  firestore: Firestore Emulator logging to firestore-debug.log
⚠  hosting: Authentication error when trying to fetch your current web app configuration, have you run firebase login?
⚠  hosting: Could not fetch web app configuration and there is no cached configuration on this machine. Check your internet connection and make sure you are authenticated. To continue, you must call firebase.initializeApp({...}) in your code before using Firebase.
i  hosting: Serving hosting files from: public
✔  hosting: Local server: http://localhost:5000
i  functions: Watching "/Users/user/src/firebase/rules-codelab/initial-state/functions" for Cloud Functions...
✔  functions[publishPost]: http function initialized (http://localhost:5001/codelab/us-central1/publishPost).
✔  functions[softDelete]: http function initialized (http://localhost:5001/codelab/us-central1/softDelete).
i  Running script: pushd functions; npm test
~/src/firebase/rules-codelab/initial-state/functions ~/src/firebase/rules-codelab/initial-state

> functions@ test /Users/user/src/firebase/rules-codelab/initial-state/functions
> mocha

(node:76619) ExperimentalWarning: Conditional exports is an experimental feature. This feature could change at any time


  Draft blog posts
    1) can be created with required fields by the author
    2) can be updated by author if immutable fields are unchanged
    3) can be read by the author and moderator

  Published blog posts
    4) can be read by everyone; created or deleted by no one
    5) can be updated by author or moderator

  Comments on published blog posts
    6) can be read by anyone with a permanent account
    7) can be created if email is verfied and not blocked
    8) can be updated by author for 1 hour after creation
    9) can be deleted by an author or moderator


  0 passing (848ms)
  9 failing

...

현재 9개의 실패가 있습니다. 규칙 파일을 빌드할 때 더 많은 테스트가 통과하는 것을 관찰하여 진행 상황을 측정할 수 있습니다.

4. 블로그 게시물 초안을 만듭니다.

블로그 초안 게시물에 대한 액세스는 게시된 블로그 게시물에 대한 액세스와 매우 다르기 때문에 이 블로그 앱은 블로그 초안 게시물을 별도의 컬렉션인 /drafts 에 저장합니다. 초안은 작성자나 중재자만 액세스할 수 있으며 필수 필드와 변경할 수 없는 필드에 대한 유효성 검사가 있습니다.

firestore.rules 파일을 열면 기본 규칙 파일을 찾을 수 있습니다.

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

match 문인 match /{document=**}** 구문을 사용하여 하위 컬렉션의 모든 문서에 반복적으로 적용합니다. 그리고 이것이 최상위 수준에 있기 때문에 지금은 누가 요청하는지, 어떤 데이터를 읽거나 쓰려고 하는지에 관계없이 모든 요청에 ​​동일한 포괄적 규칙이 적용됩니다.

가장 안쪽의 match 문을 제거하고 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 , createdAttitle 포함하는 경우에만 문서를 생성할 수 있습니다. (사용자는 createdAt 필드를 제공하지 않습니다. 이는 앱이 문서를 생성하기 전에 이 필드를 추가해야 함을 강제합니다.) 속성이 생성되는지 확인하기만 하면 되므로 request.resource 에 모든 항목이 있는지 확인할 수 있습니다. 해당 키:

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

블로그 게시물을 작성하기 위한 최종 요구 사항은 제목이 50자를 초과할 수 없다는 것입니다.

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

이러한 조건은 모두 true여야 하므로 이를 논리 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

업데이트에 대한 두 번째 요구 사항은 두 가지 속성( authorUIDcreatedAt 이 변경되어서는 안 된다는 것입니다.

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 키워드를 사용하여 isAuthorisModerator 변수를 설정합니다. 모든 함수는 return 문으로 끝나야 하며, 우리 함수는 두 변수 중 하나가 true인지 나타내는 부울 값을 반환합니다.

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

함수 호출

이제 초안이 해당 함수를 호출하도록 규칙을 업데이트하고, resource.data 첫 번째 인수로 전달하도록 주의해야 합니다.

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

이제 새 기능도 사용하는 게시된 게시물을 업데이트하기 위한 조건을 작성할 수 있습니다.

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

유효성 검사 추가

게시된 게시물의 일부 필드는 변경하면 안 됩니다. 특히 url , authorUIDpublishedAt 필드는 변경할 수 없습니다. 다른 두 필드인 title , contentvisible 업데이트 후에도 계속 존재해야 합니다. 게시된 게시물 업데이트에 대해 다음 요구 사항을 적용하려면 조건을 추가하세요.

// 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 문으로 시작하세요.

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

댓글 읽기: 익명이 될 수 없습니다

이 앱의 경우 익명 계정이 아닌 영구 계정을 만든 사용자만 댓글을 읽을 수 있습니다. 해당 규칙을 적용하려면 각 auth.token 객체에 있는 sign_in_provider 속성을 찾으세요.

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

테스트를 다시 실행하고 테스트를 하나 더 통과하는지 확인합니다.

댓글 작성: 거부 목록 확인

댓글을 작성하는 데는 세 가지 조건이 있습니다.

  • 사용자는 확인된 이메일을 가지고 있어야 합니다
  • 설명은 500자 미만이어야 합니다.
  • 그들은 bannedUsers 컬렉션의 Firestore에 저장되어 있는 금지된 사용자 목록에 포함될 수 없습니다. 다음 조건을 한 번에 하나씩 적용합니다.
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

다음으로 해당 댓글은 지난 1시간 내에 생성되었습니다.

(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 : 에뮬레이터를 사용한 로컬 최초 개발 살펴보기
  • 동영상 : GitHub Actions를 사용하여 에뮬레이터 기반 테스트를 위한 CI 설정 방법