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의 나머지 부분을 작업합니다.

$ 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 문 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, createdAt, title)가 포함된 경우에만 문서를 만들 수 있습니다. 사용자는 createdAt 필드를 제공하지 않습니다. 따라서 앱에서 문서를 만들기 전에 추가해야 합니다. 속성이 생성되고 있는지만 확인하면 되므로 request.resource에 이러한 키가 모두 있는지 확인할 수 있습니다.

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

블로그 게시물을 작성하기 위한 마지막 요구사항은 제목의 길이가 50자를 넘을 수 없다는 것입니다.

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

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

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 문 위에 post 문서 (초안 또는 게시된 글에서 작동함)와 사용자의 인증 객체를 인수로 사용하는 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 필드는 변경할 수 없습니다. 다른 두 필드 title, content, visible는 업데이트 후에도 계속 있어야 합니다. 게시된 게시물의 업데이트에 다음 요구사항을 적용하려면 조건을 추가하세요.

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

이 조건을 논리곱 연산자와 함께 사용하면 댓글 업데이트 규칙은 다음과 같이 됩니다.

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를 설정하는 방법을 알아봅니다.