I'm trying to write a security rule that only allows adding values of a certain type.
This is what I have now:
allow update if
get(/databases/$(database)/documents/users/$(request.auth.uid)).data.sessionID == key2
// THIS CHECKS IF THE USER IS ALLOWED TO UPDATE THIS DOCUMENT
&& request.resource.data.description is string
&& request.resource.data.endMo is number
&& request.resource.data.startMo is number
&& request.resource.data.openMO is bool
&& request.resource.data.pdf is string
&& request.resource.data.Adress == resource.data.Adress //USER can't update this field
&& request.resource.data.size() <= 40;
This works if all fields are already filled in.
SITUATION 1 -> WORKS
original doc
{
'description': 'helloworld',
'endMo': 12,
'startMo': 6,
'openMo': true,
'pdf': 'url',
'adress': 'myAdress',
}
db.collection("myCol").doc("myDoc").update({
'description': 'helloworld2',
}
SITUATION 2 -> DOESN'T WORK
original doc
{
'adress': 'myAdress',
}
db.collection("myCol").doc("myDoc").update({
'description': 'helloWorld',
}
Why isn't the rule accepting the adding of values and only the update of values that already exist?
Keep in mind that request.resource.data always contains all of the fields in the document, after the update would succeed. This includes all existing fields in the document.
The update in situation works because the new contents of the document satisfy all the conditions.
Ths update in the second situation doesn't work because it's failing all of the checks for fields that don't already exist in the document, and are also not being provided in the update. If you want this second situation to work, you're going to have to code the rules so that the missing fields are not actually required with specific types like they are now.
Related
The Setup
I have a collection in Firebase Firestore that has the following fields:
(["active" , "created" , "description" , "displayName" , "expires" , "image" , "type" , "uid" , "userName"])
where "expires" is optional.
The write rules ensure that every object follows that form and are tested successfully.
The Problem
When trying to read from the collection, I have a rule that states the following:
let seeUnexpired = !("expires" in resource.data.keys()) ||
resource.data.expires > request.time ||
request.auth.uid == resource.data.uid;
That prohibits users other than the author from reading expired entries. This rule was not prohibiting reads, however. I am testing using a local emulator with carefully curated data and am confident that the expire field exists and is outdated for this test.
Details
In attempt to debug I discovered that the condition causing the rule to fail was !("expires" in resource.data.keys()).
Running the tests with debug(resource.data.keys()) printed out this object to firestore-debug.log:
list_value {
values {
string_value: "active"
}
}
where "active" only shows up when using this query condition in the request: ...collection('collection_name').where('active', '==', true).get().
This indicates to me that resource.data.keys() only includes the fields from the resource that are referenced in where clauses on the read request. That means a request can circumvent a "field must not exist" rule by simply not including it in a query.
Debug:
Mar 31, 2021 12:34:48 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead
INFO: Detected non-HTTP/2 connection.
list_value {
values {
string_value: "active"
}
}
Tests
Test:
await firestoreAdmin.collection(COLLECTIONS.items).doc(mockItem.id).update({active: true, expires: new Date('01 Jan 2000 00:00:00 GMT')});
const query = firestore.collection(COLLECTIONS.items).where('active', '==', true); //unAuthed firestore instance
await assertFails(query.get());
Rule:
function readItemRules() {
let seeActive = resource.data.active == true || request.auth.uid == resource.data.uid;
let seeUnexpired = !("expires" in resource.data.keys()) ||
resource.data.expires > request.time ||
request.auth.uid == resource.data.uid; TODO figure out expiration rules
return seeActive && seeUnexpired;
}
Data:
The Question
Is my understanding of this problem accurate, or am I missing a detail or syntax quirk? Is this behavior intentional, and if so how should I modify my rules/data to enforce this kind of security?
My discovery was that for a read rule, firestore only seems to load the fields from the document which are mentioned in the query. This makes a lot of sense for rules that require the field to exist or match something, because if you don't include it in a query they're guaranteed to fail (as they will be tested against something undefined). It just doesn't work here because if you omit it from the query it'll pass the does not exist rule no matter what since firestore omits that field from the whole resource when doing the check.
My solution was simply to add another, boolean field "hasExpiration" that indicates whether or not the "expires" field will be present. I am a little unsatisfied with this solution because it adds complexity and falls on the client/write rules to ensure that there is parity between those fields.
https://firebase.google.com/docs/firestore/security/rules-query#rules_are_not_filters
https://firebase.google.com/docs/firestore/security/rules-structure#granular_operations
https://firebase.blog/posts/2021/01/code-review-security-rules
The examples they have on this page are great. Especially the final one:
allow update: if
// Immutable fields are unchanged
request.resource.data.diff(request.resource.data).unchangedKeys().hasAll([
"authorUID",
"publishedAt",
"url"
]) &&
// Required fields are present
request.resource.data.keys().hasAll([
"content",
"title",
"visible"
]);
It shows the list of keys() and a way to calculate unchangedKeys() in a smart way.
I have a Flutter app in which users can make posts and tag the post as belonging to a group. Posts are stored in a global collection and each has a Post.groupId field:
/posts/{postId}
Based on my Firestore security rules and queries, users are only allow to read posts if they are in the group for which the post is tagged (i.e the posts's groupId field). Approved group users are stored in:
/groups/{groupId}/users/{userId}
I could query the posts from a particular user's group like:
_firestore.collection('posts').where('groupId', isEqualTo: 'groupA')...
This above was all working properly.
I am attempting to make an improvement in which a post can be tagged in multiple groups instead of just one, so I am replacing the single Post.groupId field with a Post.groupIds array. A user should be able to read a post if he/she is a member of ANY of the groups from Post.groupIds. I attempt to read all posts tagged with a particular group with the following query from my Flutter app:
_firestore.collection('posts').where('groupIds', arrayContains: 'groupA')...
I keep receiving the following exception Missing or insufficient permissions with these security rules:
match /posts/{postId} {
allow read: if canActiveUserReadAnyGroupId(resource.data.groupIds);
}
function isSignedIn() {
return request.auth != null;
}
function getActiveUserId() {
return request.auth.uid;
}
function isActiveUserGroupMember(groupId) {
return isSignedIn() &&
exists(/databases/$(database)/documents/groups/$(groupId)/users/$(getActiveUserId()));
}
function canActiveUserReadAnyGroupId(groupIds) {
return groupIds != null && (
(groupIds.size() >= 1 && isActiveUserGroupMember(groupIds[0])) ||
(groupIds.size() >= 2 && isActiveUserGroupMember(groupIds[1])) ||
(groupIds.size() >= 3 && isActiveUserGroupMember(groupIds[2])) ||
(groupIds.size() >= 4 && isActiveUserGroupMember(groupIds[3])) ||
(groupIds.size() >= 5 && isActiveUserGroupMember(groupIds[4]))
);
}
With these security rules I can read a single post but I cannot make the above query. Is it possible to have security rules which allow me to make this query?
UPDATE 1
Added isSignedIn() and getActiveUserId() security rules functions for completeness.
UPDATE 2
Here is the error I am receiving when I attempt to execute this query with the Firestore Emulator locally:
FirebaseError:
Function not found error: Name: [size]. for 'list' # L215
Line 215 corresponds to the allow read line within this rule:
match /posts/{postId} {
allow read: if canActiveUserReadAnyGroupId(resource.data.groupIds);
}
It appears Firestore does not currently support security rules for this scenario at the moment (thanks for your help tracking this down Doug Stevenson). I have come up with a mechanism to work around the limitation and wanted to share in case someone else is dealing with this issue. It requires an extra query but keeps me from having to create a Web API using the Admin SDK just to get around the security rules.
Posts are stored as follows (simplified):
/posts/{postId}
- userId
- timestamp
- groupIds[]
- message
- photo
Now I am adding an additional post references collection which just stores pointer information:
/postRefs/{postId}
- userId
- timestamp
- groupIds[]
The posts collection will have security rules which does all the validation to ensure the user is in at least one of the groups in which the post is tagged. Firestore is able to handle this properly for simple get requests, just not list requests at the moment.
Since the postRefs collection stores only ID's, and not sensitive information which may be in the post, its security rules can be relaxed such that I only verify a user is logged in. So, the user will perform post queries on the postRefs collection to retrieve a list of ordered postId's to be lazily loaded from the posts collection.
Clients add/delete posts to/from the normal posts collection and then there is a Cloud Function which copies the ID information over to the postRefs collection.
As per this blog post, if you can maintain an index of member IDs for a given post (based on group assignments), then you can secure post read access storing member IDs in an array data type and matching against the member IDs with the "array-contains" clause in your ruleset. It looks like this in your Firebase rules:
service cloud.firestore {
match /databases/{database}/documents {
match /posts/{postId} {
allow read: if request.auth.uid in resource.data.members
allow write: if request.auth.uid == resource.data.owner
}
}
}
If I had to guess, I'd say that groupIds isn't actually a List type object, which means that the field from the document is also not an array. If it's a string, this code won't work, since strings don't have a method called size() in the rules language.
If you aren't 100% certain what the type of field is going to be, you will need to check the type in the rule and determine what to do with it. You can use the is operator to check the type. For example, groupIds is list will be boolean true if you're actually working with one.
In your rules, you can use the debug() function to dump the value of some expression to the log. It will return the same value. So, you can say debug(groupIds) != null to both print the value and check it for null.
I'm currently working on my firestore rules and I need to validate the incoming data. Besides what I already have, I also need to validate if the incoming origin and tag fields exist in the collection origins and tags. I've found how to do so when using references but I'm using embedded data so I'm unsure how to exactly do it.
function incomingData() {
return request.resource.data
}
function validTicket() {
return incomingData().email is string &&
incomingData().description is string &&
incomingData().address is string &&
incomingData().location is string &&
incomingData().postCode.matches('^[0-9][0-9][0-9][0-9]-[0-9][0-9][0-9]') &&
incomingData().size() == 5 &&
incomingData().hasAll(['email','description', 'address', 'location', 'postCode']) &&
isSecretary()
}
In the tags collection, every document has a single value with the tag name. The same applies to the origins.
I'm sorry my answer will be partial, i need you to post your current firestore rules, and the name of the ticket collection...
anyway, for tags, you won't be able to search them for their value, and nor inside the rules, so you should save them as keys. that mean, that the key for the document of sports, should be sports, and not 8VCCvq7qnvjyT98r95pu.
next, you will have to use the function exists, in the follow way:
function isTagExists(tag) {
return exists(/databases/$(database)/documents/tags/$(tag));
}
let me know if you updated the question or you need more help with my solution.
also you can read more at:
https://firebase.google.com/docs/firestore/security/rules-conditions
I am a bit stuck here as there is no way to debug those rules. I'd appreciate help with below rules.
I want to access:
/modules/module-id/sessions/session-id/parts/
The comparison with null in the first part of hasCompletedPrerequisiteSession() works well, the second part doesn't!
The path /modules/moduleId/sessions/sessionId/prerequisite points to a reference field.
service cloud.firestore {
match /databases/{database}/documents {
function hasCompletedPrerequisiteSession(moduleId,sessionId) {
// this part works well
return getPrerequisiteSession(moduleId,sessionId) == null ||
// !!! this part does not work !!!
hasCompleted(getPrerequisiteSession(moduleId,sessionId).id);
}
function getPrerequisiteSession(moduleId,sessionId) {
return get(/databases/$(database)/documents/modules/$(moduleId)/sessions/$(sessionId)).data.prerequisite;
}
function hasCompleted(sessionId) {
return exists(/databases/$(database)/documents/progress/$(request.auth.uid)/sessions/$(sessionId));
}
match /modules/{moduleId}/sessions/{sessionId}/parts/{partId} {
allow read: if hasCompletedPrerequisiteSession(moduleId,sessionId);
}
}
}
(If I store the session ID as a string instead of a reference to the session, it works fine.)
Edit
Questions
Reference field in security rules. Assuming modules/moduleId/owner points to a field of the type reference. What is the proper way to get the id of the referenced document?get(../modules/moduleId).data.owner.data.id or get(../modules/moduleId).data.owner or something else?
From Firebase support:
It seems that in your use case, you want to get the document name (sessionId) from the value of your reference field (prerequisite), unfortunately, this is not currently supported by Firestore security rules. I would suggest that you store only the sessionId as String on your prerequisite field, or you can also add String field for the sessionId. Keep in mind that the exists() and get() functions only allow you to check if a document exists, or retrieve the document at the given path.
It might be that around getPrerequisiteSession, after using get to pull the object by ref path, you had to use .data first before referencing the id field. Of course, id field needs to be stored as an object field.
For example, in my case I needed to allow user to add a message into a chat only if they're the owner of that chat room. There are 2 "tables" - chats and chat_messages, and chat_messages relate to a specific chat through chatId field. chats objects have ownerId field.
The rule I've used goes like this:
match /chat_messages/{itemId} {
function isOwner() {
return get(/databases/$(database)/documents/chats/$(request.resource.data.chatId)).data.ownerId == request.auth.uid;
}
allow read: if true;
allow create: if isOwner();
}
CONTEXT:
Suppose there are a products and orders collections on Firestore.
And an order has many products, that is (pseudo-schema):
products {
name: string
}
orders {
items: [{
product_id: (ref products)
quantity: number
}]
}
With these security rules:
match /products/{document=**} {
allow read, write: if request.auth != null;
}
match /orders/{document=**} {
allow read, write: if request.auth != null;
}
SCENARIO:
Now, suppose we have created the product A.
Then, we created the order 1 for the product A.
Next, suppose we delete the product A (which is already being used in the order 1) from the products collection.
This would let the order 1 still referencing the deleted product A.
QUESTION:
Is there a way of writing a Security Rule that prevents the deletion of products that are being used on orders?
First of all, if the goal is just consistency, you can use Functions to delete the references to A so there is no broken linkage.
However, if you specifically want to prevent deletion while linked, you'll need a different strategy, as there is no way to query another path within security rules at present.
Denormalize: keep a list of references in the product
Something like this in your data:
"{product_id}": {
"name": "...",
"orders": {
"order_id": true
}
}
Would allow you to write rules like this:
function isAuthenticated() {
return auth != null;
}
function hasLinks(resource) {
return !resource.data.links;
}
match /products/{pid} {
allow delete: if isAuthenticated() && !hasLinks(request.resource);
}
Other ideas
Using Functions: My first instinct was a queue approach (you queue the delete to the server and the serve decides if there is a link to the product and either deletes it or rejects the request). But that wouldn't work with your current structure either since you can't query across subcollections to find references to the product in each order subcollection. You'd still need a denormalized list of orders to products to use for this (making this pretty much the same as my solution above).
Archive items instead of deleting them: There's probably not a strong need to actually delete items, so archiving them instead could avoid the whole problem set.