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 에뮬레이터 도구 모음을 사용하여 로컬에서 이루어집니다.

소스 코드 가져오기

이 Codelab에서는 보안 규칙 테스트로 시작하지만 보안 규칙 자체는 최소한이므로 테스트를 실행하려면 먼저 소스를 클론해야 합니다.

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

그런 다음 이 Codelab의 나머지 작업을 수행할 initial-state 디렉터리로 이동합니다.

$ 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. 테스트 실행

이 섹션에서는 테스트를 로컬로 실행합니다. 이제 에뮬레이터 도구 모음을 부팅할 시간입니다.

에뮬레이터 시작

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

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

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

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

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

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

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

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


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

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

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


  0 passing (848ms)
  9 failing

...

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

4. 블로그 게시물 초안을 작성합니다.

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

firestore.rules 파일을 열면 기본 규칙 파일이 표시됩니다.

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

일치 문 match /{document=**}** 구문을 사용하여 하위 컬렉션의 모든 문서에 재귀적으로 적용됩니다. 또한 최상위 수준에 있으므로 현재는 요청을 하는 사용자가 누구인지, 읽거나 쓰려는 데이터가 무엇인지와 관계없이 모든 요청에 동일한 포괄적 규칙이 적용됩니다.

가장 안쪽의 일치 문을 삭제하고 match /drafts/{draftID}로 바꿉니다. (문서 구조에 관한 주석은 규칙에 유용할 수 있으며 이 Codelab에 포함됩니다. 항상 선택사항입니다.)

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional
    }
  }
}

초안에 대해 작성할 첫 번째 규칙은 문서를 만들 수 있는 사용자를 관리합니다. 이 애플리케이션에서는 작성자로 등록된 사용자만 초안을 만들 수 있습니다. 요청하는 사용자의 UID가 문서에 나열된 UID와 동일한지 확인합니다.

생성을 위한 첫 번째 조건은 다음과 같습니다.

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

다음으로, 필수 필드인 authorUID, createdAt, title가 포함된 경우에만 문서를 만들 수 있습니다. (사용자가 createdAt 필드를 제공하지 않습니다. 이는 앱이 문서를 만들기 전에 이 필드를 추가해야 함을 강제합니다.) 속성이 생성되는지 확인하기만 하면 되므로 request.resource에 이러한 모든 키가 있는지 확인할 수 있습니다.

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

블로그 게시물을 만들기 위한 마지막 요구사항은 제목이 50자(영문 기준)를 초과할 수 없다는 것입니다.

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

이러한 조건이 모두 참이어야 하므로 논리 AND 연산자 &&로 연결합니다. 첫 번째 규칙은 다음과 같이 됩니다.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;
    }
  }
}

터미널에서 테스트를 다시 실행하고 첫 번째 테스트가 통과하는지 확인합니다.

5. 블로그 게시물 초안을 업데이트합니다.

그런 다음 작성자가 블로그 게시물 초안을 수정할 때 초안 문서를 수정합니다. 게시물을 업데이트할 수 있는 조건에 대한 규칙을 만듭니다. 먼저 작성자만 임시보관함을 업데이트할 수 있습니다. 여기서는 이미 작성된 UID를 확인합니다.resource.data.authorUID

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

업데이트의 두 번째 요구사항은 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. 게시된 게시물의 읽기, 생성, 삭제: 다양한 액세스 패턴을 위해 비정규화

게시된 게시물과 임시 저장된 게시물의 액세스 패턴이 매우 다르기 때문에 이 앱은 게시물을 별도의 draftpublished 컬렉션으로 비정규화합니다. 예를 들어 게시된 글은 누구나 읽을 수 있지만 완전히 삭제할 수는 없으며, 임시보관 글은 삭제할 수 있지만 작성자와 모더레이터만 읽을 수 있습니다. 이 앱에서 사용자가 초안 블로그 게시물을 게시하려고 하면 새 게시물을 만드는 함수가 트리거됩니다.

다음으로 게시된 게시물에 대한 규칙을 작성합니다. 작성하기 가장 간단한 규칙은 게시된 글은 누구나 읽을 수 있으며 누구나 만들거나 삭제할 수는 없다는 것입니다. 다음 규칙을 추가합니다.

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. 게시된 게시물 업데이트: 맞춤 함수 및 지역 변수

게시된 게시물을 업데이트하기 위한 조건은 다음과 같습니다.

  • 작성자 또는 운영자만 삭제할 수 있습니다.
  • 모든 필수 입력란을 포함해야 합니다.

이미 작성자 또는 관리자가 되기 위한 조건을 작성했으므로 조건을 복사하여 붙여넣을 수 있지만 시간이 지남에 따라 읽고 유지하기 어려워질 수 있습니다. 대신 작성자 또는 관리자가 되는 로직을 캡슐화하는 맞춤 함수를 만듭니다. 그런 다음 여러 조건에서 이를 호출합니다.

맞춤 함수 만들기

초안의 match 문 위에 게시물 문서 (초안 또는 게시된 게시물에 모두 적용됨)와 사용자의 인증 객체를 인수로 사용하는 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, authorUID, publishedAt 필드는 변경할 수 없습니다. 업데이트 후에도 다른 두 필드인 titlecontentvisible은 계속 있어야 합니다. 게시된 게시물 업데이트에 이러한 요구사항을 적용하는 조건을 추가합니다.

// Immutable fields are unchanged
request.resource.data.diff(resource.data).unchangedKeys().hasAll([
  "authorUID",
  "publishedAt",
  "url"
]) &&
// Required fields are present
request.resource.data.keys().hasAll([
  "content",
  "title",
  "visible"
])

맞춤 함수 직접 만들기

마지막으로 제목이 50자(영문 기준) 미만이어야 한다는 조건을 추가합니다. 재사용되는 로직이므로 새 함수 titleIsUnder50Chars를 만들어 이 작업을 실행할 수 있습니다. 새 함수를 사용하면 게시된 게시물을 업데이트하는 조건이 다음과 같이 변경됩니다.

allow update: if
  isAuthorOrModerator(resource.data, request.auth) &&
  // Immutable fields are unchanged
  request.resource.data.diff(resource.data).unchangedKeys().hasAll([
    "authorUID",
    "publishedAt",
    "url"
  ]) &&
  // Required fields are present
  request.resource.data.keys().hasAll([
    "content",
    "title",
    "visible"
  ]) &&
  titleIsUnder50Chars(request.resource.data);

전체 규칙 파일은 다음과 같습니다.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {
      let isAuthor = auth.uid == post.authorUID;
      let isModerator = auth.token.isModerator == true;
      return isAuthor || isModerator;
    }

    function titleIsUnder50Chars(post) {
      return post.title.size() < 50;
    }

    // Draft blog posts
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        titleIsUnder50Chars(request.resource.data);

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
          ]) &&
        titleIsUnder50Chars(request.resource.data);

      // Can be read or deleted by author or moderator
      allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
    }

    // Published blog posts are denormalized from drafts
    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;

      allow update: if
        isAuthorOrModerator(resource.data, request.auth) &&
        // Immutable fields are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "publishedAt",
          "url"
        ]) &&
        // Required fields are present
        request.resource.data.keys().hasAll([
          "content",
          "title",
          "visible"
        ]) &&
        titleIsUnder50Chars(request.resource.data);
    }
  }
}

테스트를 다시 실행합니다. 이제 통과한 테스트가 5개, 실패한 테스트가 4개 있어야 합니다.

9. 의견: 하위 컬렉션 및 로그인 제공업체 권한

게시된 게시물은 댓글을 허용하며 댓글은 게시된 게시물의 하위 컬렉션 (/published/{postID}/comments/{commentID})에 저장됩니다. 기본적으로 컬렉션의 규칙은 하위 컬렉션에 적용되지 않습니다. 게시된 게시물의 상위 문서에 적용되는 동일한 규칙이 댓글에 적용되지 않도록 하려면 다른 규칙을 작성합니다.

댓글 액세스 규칙을 작성하려면 일치 문으로 시작하세요.

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

댓글 읽기: 익명으로 할 수 없음

이 앱의 경우 익명 계정이 아닌 영구 계정을 만든 사용자만 댓글을 읽을 수 있습니다. 이 규칙을 적용하려면 각 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. 댓글 업데이트: 시간 기반 규칙

댓글의 비즈니스 로직은 댓글 작성자가 생성 후 1시간 동안 댓글을 수정할 수 있다는 것입니다. 이를 구현하려면 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 작업을 사용하여 에뮬레이터 기반 테스트를 위한 CI를 설정하는 방법