Firestore: Round-robin access to document in collection - firebase

I want to do something very simple, but not sure the best way to do this with Firestore.
I have an ads collection.
Each time an ad is accessed, I want to update the accessed property timestamp so I can just show the ad that hasn't been shown in the longest amount of time.
My security rules only allow users that carry a token with a payload of admin:true to create/modify ads.
So, from within the app, I can't update the timestamp each time an ad is accessed because the users aren't admins.
I looked at creating a function for this but realized that there is no onGet function that would allow me to do this (https://firebase.google.com/docs/functions/firestore-events)
I don't see anyway to allow a single property to be modified by any user.
What would be an appropriate way to do this with Firestore?

You could solve this either by creating a quite comprehensive rules validation where you make a check that all fields except accessed are unchanged. You can implement the admin role concept with custom claims as described in the answer on this post.
Checking that all fields except accessed are unchanged requires you to list and check all fields one by one.
service cloud.firestore {
match /databases/{database}/documents {
match /ads/{id} {
allow read: if true;
allow write: if request.auth.token.admin == true
|| (request.resource.data.someField == resource.data.someField
&& request.resource.data.anotherField == resource.data.anotherField);
}
}
}
Another way, you could do it is to create a callable cloud function that works similar to the Unix touch command. You simply call it from your client for every time your read an ad and you can safely update the accessed field on the post within that function.
export const touchAd = functions.https.onCall((data, context) => {
const adId = data.id;
return admin.firestore().collection('ads').doc(adId).update({
accessed: firebase.firestore.FieldValue.serverTimestamp(),
}));
});

Related

Firebase cross-service Security Rules not working in application

I'm trying to use the new Firebase cross-service Security Rules (https://firebase.blog/posts/2022/09/announcing-cross-service-security-rules) but I having some problems with Storage Rules accessing to Firestore data.
The problem seems to be with userIsCreator() function
match /certification/{certificationId}/{fileId} {
function userIsCreator() {
let certification = firestore.get(/databases/(default)/documents/certifications/$(certificationId));
return firestore.get(certification.data.creatorRef).id == request.auth.uid;
}
allow read, write: if userIsCreator()
}
The content of the Firestore Document is:
{
"data": {
othersValues,
"creatorRef": "/databases/%28default%29/documents/users/CuutSAtFkDX2F9T8hlT4pjMUByS2"
}
"id": "3EhQakDrsKxlacUjdibs"
"__name__":
"/databases/%28default%29/documents/certifications/3EhQakDrsKxlacUjdibs"
}
The creatorRef variable is a reference to a Firestore Document to user. Inside Users collection, the doc id is the UID of an user, so I'm obtaining the creatorRef of an item and then checking if the id of that user collection referenced is the same UID that user logged in.
The same function is working for Firestore Rules to avoid updating certification document if not the creator, without any problem.
It seems to be a problem calling to firestore.get to creatorRef after obtaining it but it not make sense!
Tested:
If I use Firestore Storage Rules validator, it is not failing and it says I have access to that resource from the UID typed in the tester (for other UID is failing as expected). But in my app, even logged in with creator user is getting permission error.
If changing the function to only one call directly to the Users collection id (return firestore.get(/databases/(default)/documents/users/CuutSAtFkDX2F9T8hlT4pjMUByS2).id == request.auth.uid;), it is working in the tester and my app. But it isn't a solution because I need to get first the Users collection ref for the creator!
For original function in the tester It's getting the variables as expected and returning true if simulate the creator UID! But for any reason, in the real app access it is getting unauthorized if making both calls!
Firebaser here!
It looks like you've found a bug in our implementation of cross-service rules. With that said, your example will create two reads against Firestore but it's possible to simplify this to avoid the second read.
Removing the second read
From your post:
return firestore.get(certification.data.creatorRef).id == request.auth.uid;
This line is a bit redundant; the id field is already contained in the certification.data.creatorRef path. Assuming you are indeed using Firestore document references, the format of creatorRef will be /projects/<your-project-id>/databases/(default)/documents/users/<some-user-id>. You can therefore update your function to the following:
function userIsCreator() {
let certification = firestore.get(/databases/(default)/documents/certifications/$(certification));
let creatorRef = certification.data.creatorRef;
// Make sure to replace <your-project-id> with your project's actual ID
return creatorRef ==
/projects/<your-project-id>/databases/(default)/documents/users/$(request.auth.uid);
}
I've tested this out in the emulator and in production and it works as expected. The benefit of doing it this way is you only have to read from Firestore once, plus it works around the bug you've discovered.

Prevent firebase function from overwriting existing data

I am moving the process of creating users in my application to a firebase function for a couple of reasons but I am running into an issue:
I have a /users ref and a /usernames, when a user is created I persist their info in users and usernames (which is publicly accessible to see if a username is available) as a transaction so the username is added immediately when a user is created and my security rules prevent overriding existing data.
However, with firebase functions these security rules are bypassed so there could be a case where 2 users signup with the same username and one person's data will be overriden by the other
is there a way to prevent overriding existing data from cloud functions? (ideally without having them go through the security rules)
I ran into a similar issue and the best solution i found was using the transaction method that firebase offers.
assuming you have a usernames ref you could do something like this:
db.ref('usernames').child(theUsername).transaction(function (usernameInfo) {
if (usernameInfo === null) {
return {
...anObjectOfUserData // or could just return true
}
}
// if the data is not null it means the username is used
// returning nothing makes the transaction do no updates
return
}, function (error, isComitted, snap) {
//
// Use isCommitted from the onComplete function to determine if data was commited
// DO NOT USE the onUpdate function to do this as it will almost certainly run a couple of times
//
})

An issue with preventing users from changing specific document fields in Firestore

I'm using firebase in my Android project, I have a users collection in the Cloud Firestore database, users can only update some fields, but can read all, I know it is not possible to protect a subset of a document from being updated, and security rules can be applied only to the entire document, so I searched about this and the only solution I found is to make a sub-collection inside the user document and create a new document inside this sub-collection and finally put fields I want to protect in there, then I can easily secure this document applying this code in security rules section:
match /users/{userId}/securedData/{securedData} {
allow write: if false;
allow read: if true;
}
So now no one can write these fields, but anyone can read, and this is exactly what I want, but later I found that I need to query users based on fields inside the sub-collection, what is known as a collection group query which is not supported at the moment, so I ended up with two solutions:
Retrieve all users and filter them at the client side, but this will increase the number of the document reads because I'm querying all users collection documents + an extra sub-collection document read for each user to get the needed field for filtering users.
Instead of making a sub-collection inside user document and incurring these problems, just keep all fields in the top level document(user document), allowing users to update any field.
match /users/{userId} {
allow update, read: if true;
allow create, remove: if false;
}
But make an (onUpdate) cloud function to listen on user document update and
detect changes in fields which aren't allowed to be modified by the user, so
if the user tried to change these fields I can detect this and return modified fields to their previous values like this:
export const updateUser = functions.firestore
.document('users/{userId}')
.onUpdate((change, context) => {
const previousValue = change.before.data().securedField;
const newValue = change.after.data().securedField;
if (previousValue !== newValue) {
return db.collection('users')
.doc(context.params.userId)
.update({ securedField: previousValue });
}
});
Is the second solution secure? which is the best solution for this? or any other solutions?
Your approach is definitely a possibility, and an interesting use of Cloud Functions. But there will be a delay between the write operation from the user, and the moment the Cloud Function detects and reverts the change.
I'd probably catch the situation in security rules. While you can't deny the user from writing the field in security rules, you can ensure that they can only write the same value to a field that currently has. That effectively also makes it impossible for them to change the value of a field. You do this by:
allow write: if request.resource.data.securedField == resource.data.securedField;
This rule ensures that the field in the updated document (request.resource.data.securedField) has the same value as that field in the current document(resource.data.securedField).

Firestore security rules, restricting child/field

What is the equivalent way of Firebase RTDB's newData.hasChildren(['name', 'age', 'gender']) in Firestore? How to restrict the child/field?
Update:
I have updated my question with Firestore rules and explained my issue in detail.
match /{country} {
allow read: if true;
allow create: if isAdministrator()
&& incomingData().countryCode is string
&& incomingData().dialCode is string;
allow update: if (isAdministrator() || isAdminEditor())
&& incomingData().countryCode is string
&& incomingData().dialCode is string;
allow delete: if isAdministrator();
}
create, read and delete is working as expected. But if I try to update using Hashmap with any unmentioned child, it will update without throwing any exception unlike Firebase Database rules, where we mention all the possible childs in newData.hasChildren([]).
What you're doing right now is just checking if two provided field values are strings. You're not requiring that only those to fields exist in the update data. What you can do is use the keys() method of the data map to verify that only certain fields exist in the update. For example, this may work:
request.resource.data.keys().hasOnly(['countryCode', 'dialCode'])
There are a number of other methods available on List objects to help you determine its contents.

Firestore security rules based on map values

I want to store if a user is permitted to read a document in the document itself, based on the user's email address. Multiple users should have access to the same document.
According to the documentation Firestore does not allow querying array members. That'S why I'm storing the users email addresses in a String-Bool Map with the email address as a key.
For the following example I'm not using emails as map keys, because it already doesn't work with basic strings.
The database structure looks like that:
lists
list_1
id: String
name: String
owner: E-Mail
type: String
shared:
test: true
All security rules are listed here:
service cloud.firestore {
match /databases/{database}/documents {
match /lists/{listId=**} {
allow read: if resource.data.shared.test == true
}
}
}
Edit: It also doesn't work if I use match /lists/{listId} instead of match /lists/{listId=**}
How I understand it, this security rules should allow reading access to everyone if the value in the map shared[test] is true.
For completness sake: This is the query I'm using (Kotlin on Android):
collection.whereEqualTo("shared.test", true).get()
.addOnCompleteListener(activity, { task ->
if (task.isSuccessful) {
Log.i("FIRESTORE", "Query was successful")
} else {
Log.e("FIRESTORE", "Failed to query existing from Firestore. Error ${task.exception}")
}
})
I'm guessing that I cannot access map values from the security rules. So what would be an alternative solution to my problem?
In the Firestore rules reference it's written that maps can be accessed like that resource.data.property == 'property' so, what am I doing wrong?
Edit: This issue should be fixed now. If you're still seeing it (and are sure it's a bug with the rules evaluator), let me know in the comments.
I've chatted with some folks here about the problem you're encountering, and it appears to be an issue with the security rules itself. Essentially, the problem seems to be specific to evaluating nested fields in queries, like what you're doing.
So, basically, what you're doing should work fine, and you'll need to wait for an update from the Firestore team to make this query work. I'll try to remember to update this answer when that happens. Sorry 'bout that!
Whenever you have (optional) nested properties you should make sure the property exists before continuing to check its' value eg.
allow read: if role in request.auth.token && request.auth.token[role] == true
in your case:
allow read: if test in resource.data.shared && resource.data.shared.test == true
, I was struggling a long time with roles until I realized that on non-admin users the admin field is undefined and firestore rules just crashes and doesn't continue checking other possible matches.
For a user without token.admin, this will always crash no matter if you have other matches that are true eg:
function userHasRole(role) {
return isSignedIn() && request.auth.token[role] == true
}

Resources