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 컬렉션에 있는 임시 블로그 게시물을 만들 수 있습니다.
  • 작성자는 게시 준비가 완료될 때까지 초안을 계속 업데이트할 수 있습니다.
  • 게시할 준비가 되면 Firebase 함수가 트리거되어 published 컬렉션에 새 문서를 만듭니다.
  • 작성자 또는 사이트 운영자가 초안을 삭제할 수 있습니다.

게시된 블로그 게시물:

  • 게시된 게시물은 사용자가 만들 수 없으며 함수를 통해서만 만들 수 있습니다.
  • 소프트 삭제만 가능하며, 이 경우 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

이러한 조건이 모두 충족되어야 하므로 이 조건을 논리적 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 필드는 변경할 수 없습니다. 다른 두 필드 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. 댓글 업데이트: 시간 기반 규칙

댓글의 비즈니스 로직은 댓글 작성자가 댓글 작성 후 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 Actions를 사용하여 에뮬레이터 기반 테스트를 위한 CI 설정을 사용하는 방법