채팅 메시지 목록과 함께 채팅 참가를 허용할 사람들의 목록이 있습니다. (물론, 실제 환경에서 이런 userID는 user_abc보다 훨씬 더 지저분하게 보이겠죠.)
우리가 설정해야하는 첫 번째 규칙은 구성원 목록에 포함된 사람들만 채팅 메시지를 보도록 허용하는 것입니다. 다음과 같이 일련의 보안 규칙을 사용하여 생성할 수 있습니다.
{
"rules": {
"chats": {
"$chatID": {
"messages": {
".read": "data.parent().child('members').child(auth.uid).exists()"
}
}
}
}
}
이 규칙은 사용자의
userID
가 읽으려고 하는 채팅의
members
섹션에 존재하는 경우에만
chats//messages
에서 채팅 메시지를 읽을 수 있도록 합니다.
$chatID
행이 좀 이상해 보이세요? 이는 해당 위치의 항목을 나타내는 와일드 카드며, 나중에 참조할 수 있도록
$chatID
변수에 일치 항목을 할당합니다.
그렇다면
user_abc
는 어떨까요? 완전히 채팅 메시지를 읽을 수 있습니다. 하지만,
user_xyz
는 해당 채팅 그룹 내에
members/user_xyz
항목이 없으므로 읽을 수 없죠.
이 작업을 수행한 후에는 단순하게 구성원만 채팅 메시지를 쓸 수 있다는 내용을 포함하는 유사한 규칙을 추가하면 됩니다.
"chats": {
"$chatID": {
"messages": {
".read": "data.parent().child('members').child(auth.uid).exists()",
".write": "data.parent().child('members').child(auth.uid).exists()"
}
}
}
원한다면 더욱 세분화할 수 있습니다. 메시지를 볼 수는 있지만 쓰기는 허용되지 않는
"lurker"
사용자 유형이 있다면 어떻게 해야 할까요?
이 문제도 해결할 수 있습니다. '메시지를 쓸 수 있지만 소유자 또는 채팅 참가자로 등록된 경우에만 그럴 수 있다'는 내용을 포함하도록 규칙을 변경하면 됩니다. 이 경우 최종 규칙은 다음과 같습니다.
"chats": {
"$chatID": {
"messages": {
".read": "data.parent().child('members').child(auth.uid).exists()",
".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'"
}
}
}
(참고로 간략히 보여주려고 이 샘플의 나머지 부분에서는 "rules" 행을 삭제했습니다.)
그런데 "사람들이 lurker로 등록되지 않은 경우에만 채팅 메시지를 쓰도록 허용하는 것이 더 쉽지 않을까?"라고 생각하실지도 모르겠군요. 물론 그렇게 하면 코드가 한 줄 덜 필요하겠죠.
"chats": {
"$chatID": {
"messages": {
".read": "data.parent().child('members').child(auth.uid).exists()",
".write": "data.parent().child('members').child(auth.uid).val() != 'lurker'"
}
}
하지만 보안과 관련하여 금지 목록보다 허용 목록을 기반으로 하는 것이 더 좋은 경우가 꽤 많습니다. 다음과 같은 경우를 생각해보세요. 앱에서 갑자기 "newbies"라고 부르는 사용자로 구성된 새로운 클래스를 추가하기로 결정 했는데 이 규칙을 업데이트하는 것을 잊어버렸다면 어떻게 될까요?
첫 번째 규칙 집합에서는 새로운 사용자 그룹이 어떤 것도 게시할 수 없지만, 두 번째 규칙 집합에서는 이 새로운 사용자 그룹이 원하는 무엇이든 게시할 수 있습니다. 어느 경우든 의도한 결과는 아니지만, 보안 관점에서는 후자는 더 큰 문제가 될 수 있습니다.
물론 이 모든 것은 아주 작은 문제 하나를 간과하고 있습니다. 우선 첫째로 우리가 이러한 사용자 목록을 어떻게 구성할 수 있었습니까?
여기서 잠깐만, 사용자가 어떤 방법으로든 앱을 통해 친구 목록을 확보할 수 있다고 가정해 봅니다. (이것은 독자를 위한 연습 문제로 남겨놓겠습니다.) 그룹 채팅에 새 사용자를 추가할 때 고려할 수 있는 몇 가지 옵션이 있습니다.
- 채팅에 이미 참가한 누구든 다른 사람을 채팅에 추가할 수 있습니다.
- 채팅의 소유자만 다른 사람을 추가할 수 있습니다.
- 누구든 채팅 참가를 요청할 수 있지만 채팅 소유자가 이런 요청을 승인해야 참가할 수 있습니다.
솔직히, 이런 옵션 모두 가능하지만 무엇이 앱에 있어 최상의 사용자 환경일지 결정하는 것은 앱 개발자에게 달린 문제입니다.
그럼 이제 이에 대해 순서대로 살펴보도록 하겠습니다.
채팅에 이미 참가한 누구든 다른 사람을 채팅에 추가할 수 있습니다.
이 첫 번째 옵션을 다루려면 '이미 구성원 목록에 포함된 사람들은 구성원 목록에 메시지를 쓸 수 있다'는 내용을 포함하는 규칙을 설정해야 합니다.
다음은 구성원 목록에 게시하기 위해 이미 설정했던 규칙과 매우 흡사합니다.
"chats": {
"$chatID": {
"messages": {
".read": "data.parent().child('members').child(auth.uid).exists()",
".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'"
},
"members": {
".read": "data.child(auth.uid).exists()",
".write": "data.child(auth.uid).exists()"
}
}
}
기본적으로, 현재 사용자 ID가 구성원 목록에 이미 존재하는 사용자는 누구든 이 목록에서 메시지를 읽거나 쓸 수 있습니다.
채팅의 소유자만 다른 사람을 추가할 수 있습니다.
소유자만 목록에 메시지를 쓸 수 있도록 이 옵션을 제한하는 것도 쉽습니다.
"chats": {
"$chatID": {
"messages": {
".read": "data.parent().child('members').child(auth.uid).exists()",
".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'"
},
"members": {
".read": "data.child(auth.uid).val() == 'owner'",
".write": "data.child(auth.uid).val() == 'owner'"
}
}
}
즉, 여기서는 '계속 진행하여 채팅에 참가한 구성원의 브랜치에 메시지를 쓸 수 있지만, userID가 이미 거기에 있고 소유자로 등록된 경우에만 가능하다'고 지정하는 것이죠.
이로써 두 번째 케이스를 설명했습니다.
누구든 채팅 참가를 요청할 수 있지만, 채팅 소유자가 이런 요청을 승인해야 참가할 수 있습니다.
그렇다면, 사용자에게 참가 요청을 허용하고 채팅 소유자가 이런 요청을 승인하도록 한다는 아이디어는 어떠세요? 이를 위해 한 가지 좋은 옵션은 사람들이 자기 자신을 추가할 수 있는
members
목록과 함께 데이터베이스에 대기 목록을 추가하는 것입니다.
그러면 그룹의 소유자가 이런 잠재적인 사용자를 구성원 목록에 추가하고 대기 목록에서 삭제할 수 있게 됩니다.
따라서 '자기 자신을 추가하는 경우에만 대기 목록에 항목을 추가할 수 있다'는 규칙을 가장 먼저 선언해야 합니다. 다시 말해, 추가할 항목의 키는 자기 자신의 사용자 ID여야 합니다.
자기 자신의 사용자 ID를 추가하는 규칙은 다음과 같습니다.
"chats": {
"$chatID": {
"messages": {
".read": "data.parent().child('members').child(auth.uid).exists()",
".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'"
},
"members": {
".read": "data.child(auth.uid).val() == 'owner'",
".write": "data.child(auth.uid).val() == 'owner'"
},
"pending": {
"$uid": {
".write": "$uid === auth.uid"
}
}
}
}
여기서는 '계속 진행하여 원하는 어떠한 메시지든
pending/
브랜치에 쓰되, 이 경우 uid가 자기 자신의
userID
여야 한다'고 지정하는 것입니다.
철저히 하려면 대기("pending") 목록에 자기 자신을 아직 추가하지 않은 경우에만 이를 수행할 수 있도록 지정할 수도 있습니다. 이에 대한 코드는 다음과 조금 더 비슷합니다.
"pending": {
"$uid": {
".write": "$uid === auth.uid && !data.exists()"
}
}
여기서 자신이 이미 채팅 구성원인 경우 자기 자신을 추가해 줄 것을 요청할 수 없도록 하는 규칙도 지정해봅시다. 이는 무의미할 수 있습니다. 이 경우, 최종 규칙은 다음과 같습니다.
"pending": {
"$uid": {
".write": "$uid === auth.uid && !data.exists() && !data.parent().parent().child('members').child($uid).exists()"
}
}
그런 다음, 몇몇 규칙을 소유자가 이 폴더에 메시지를 읽거나 쓸 수 있음을 나타내는 전체
pending
폴더에 추가할 수 있습니다.
"pending": {
".read": "data.parent().child('members').child(auth.uid).val() === 'owner'",
".write": "data.parent().child('members').child(auth.uid).val() === 'owner'",
"$uid": {
".write": "$uid === auth.uid && !data.exists() && !data.parent().parent().child('members').child($uid).exists()"
}
}
이게 다입니다! 채팅의 소유자만 구성원 목록을 읽거나 이 목록에 쓸 수 있도록 허용하는 이전 섹션의 규칙을 유지한다는 가정 하에, 소유자가 대기 목록에서 항목을 제거하고 구성원 목록에 추가할 수 있도록 허용하는 보안 규칙을 성공적으로 추가했습니다.
하지만 마지막으로 한 가지 중요한 규칙을 놓친 것 같습니다. 바로, 특정 사용자가 새 그룹 채팅을 생성할 수 있도록 허용하는 것입니다. 어떻게 하면 이 규칙을 설정할 수 있을까요? 이 문제를 생각하고 있다면, '구성원 목록이 비어 있고 자기 자신을 소유자로 설정하는 경우 누구나 구성원 목록에 메시지를 쓸 수 있다'라는 내용을 명시하여 이 규칙을 추가할 수 있습니다.
"members": {
".read": "data.child(auth.uid).val() == 'owner'",
".write": "data.child(auth.uid).val() == 'owner' ||(!data.exists()&&newData.child(auth.uid).val()=='owner')"
}
이런 쓰기를 위한 목적으로,
{ "user_zzz" : "owner" }
객체와 함께
/chats/chat_345/members
에 메시지를 쓰는 것부터 시작한다고 생각해 보세요. 해당 newData 행은 이 객체를 검토하고 로그인한 사용자
(user_zzz)
의 키가 있는 하위 항목이 소유자로 등록되어 있는지 확인합니다.
이 작업을 수행하고 나면 작업을 계속 진행하여 소유자가 원하는 추가 메시지나 사용자를 모두 추가할 수 있습니다. 이런 사용자가 이제 소유자로 공식적으로 등록되었으므로, 보안 규칙에서 이런 작업에 아무런 문제가 없도록 허용해야 합니다.
참고로, 보안 규칙에는 별도의 '디렉터리 생성' 작업에 대한 개념이 없습니다. 사용자가
chat_456/messages/abc
에 메시지를 쓸 수 있도록 허용된 경우 이 규칙은 메시지가 이미 존재하는지에 상관없이 적용됩니다. (또는 그 문제의 경우에는
chat_456
입니다.)
제가 이 모든 것을 어떻게 알게 됐냐고요?
제가 Firebase 보안 전문가는 아니지만, 블로그 게시물에서는 보안 전문가 노릇을 할 수 있습니다. 대개는 규칙 시뮬레이터를 실행하는 방법으로 말이죠.
규칙을 변경할 때마다, 그리고 이런 규칙을 게시하기 전에 데이터베이스에 대한 읽기 또는 쓰기를 시뮬레이션함으로써 규칙이 실행되는 방식을 테스트할 수 있습니다. Firebase 콘솔의 Rules 섹션에서 오른쪽 위에 'Simulator' 버튼이 있는데, 그걸 클릭하면 됩니다. 이 버튼을 클릭하면 원하는 모든 종류의 읽기 또는 쓰기 작업을 테스트할 수 있는 양식이 표시됩니다.
이 예시에서는
"user_zzz"
로 로그인한 사용자가 빈
/chats/chat_987/members
목록에 자기 자신을 소유자로 추가하는 작업을 시도함으로써 마지막 규칙을 테스트해 보겠습니다. 규칙 시뮬레이터는 이 작업이 허용됨을 알리고 쓰기 작업이 true로 평가되는 행을 강조표시합니다.
(기술적으로는 잘못된 행을 강조표시하는 것입니다. 이는 규칙의 13단계에서 true로 평가하는 부분입니다. 저는 강조표시기가 문자열 내에 있는 줄바꿈을 특별히 잘 처리한다고 생각하지 않습니다.)
반면에, 사용자가 비어 있지 않은 목록의 소유자로 자기 자신을 추가하려고 하면 실패하며, 바로 이것이 우리가 정확히 원하는 결과입니다.
추가 개선 사항
여기에는 몇 가지 개선할 수 있는 다른 사항이 있습니다. 지금 여기서는 소유자가 다른 구성원을 소유자로 추가할 수 있도록 설정했습니다. 이는 우리가 원하는 바일 수도, 그렇지 않을 수도 있습니다.
생각해 보니, 새 구성원이 적당한 역할로 추가되는지 검증하기 위한 어떤 작업도 수행하지 않았군요. 또한, 채팅 메시지의 길이가 UI가 처리할 수 있는 수준인지 확인하기 위해 채팅 메시지에 추가할 수 있는 몇 가지 특정한 검증 규칙이 있습니다. 하지만 아마도 이는 여러분이 해결할 수 있는 문제일 겁니다.
이 최종 규칙을 각자 고유의 채팅 앱 버전에 복사하고 붙여 넣은 후 이런 개선 사항을 추가하기 위해 무엇을 할 수 있을지 알아보세요.
{
"rules": {
"chats": {
"$chatID": {
"messages": {
".read": "data.parent().child('members').child(auth.uid).exists()",
".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'"
},
"members": {
".read": "data.child(auth.uid).val() == 'owner'",
".write": "data.child(auth.uid).val() == 'owner' ||(!data.exists()&&newData.child(auth.uid).val()=='owner')"
},
"pending": {
".read": "data.parent().child('members').child(auth.uid).val() === 'owner'",
".write": "data.parent().child('members').child(auth.uid).val() === 'owner'",
"$uid": {
".write": "$uid === auth.uid && !data.exists() && !data.parent().parent().child('members').child($uid).exists()"
}
}
}
}
}
}
도움이 더 필요하다면 관련
문서를 확인하고 시뮬레이터로 이런저런 실험을 해보시기 바랍니다! 아마 이번 주에는 데이터베이스 보안 규칙 시뮬레이터로 이것저것 시도해보는 시간이 가장 즐거운 시간이 될 것이라 장담합니다. 뭐, 꼭 가장 즐거운 시간은 아니라도 세 번째 정도로 즐거운 시간은 되실 거예요.