Firestore - proper NoSQL structure for user-specific data - firebase

I'm working on an app with Firestore. I use Firebase Authentication for managing users.
The thing is that most of the data in my app would be user-specific. Meaning that users should only have access to their own data.
I've already done research about Firestore security rules, so I know how to handle the security part.
However, I'm wondering how to properly model user-specific data in general. First piece of data I'm adding are categories, that have (for now) 2 properties: name and type.
After some research, I found it's good to create a collection called categories and then a document per-user, each named after the user's ID (UID).
But then, within such a user-specific document, I want to add my categories. One way I figured it out is like on the screenshot below:
So, to describe this approach in a more generic way:
New collection for each data type (e.g. categories)
Inside the collection, separate document named with UID for each user
In each user-specific document, a map with an object's data (e.g. category_1 map with fields name = groceries and type = expense
However, what worries me here is that I need to somehow invent these names of the maps like category_1, category_2 etc... I have a feeling something is wrong in this model, but my strong SQL background doesn't allow me to think that through 😉
Do you have any ideas whether this model is a good one or what problems could it produce later? Maybe you can suggest a better approach for modeling user-specific data in Firestore database?

Is there any limit on how many categories can a single user have? If not then it'll be better to create a collection for categories to avoid hitting 1 MB max document size. If there is a limit and you decide to use a map, I'd recommend creating a map field categories and them as it's children as shown below so if you add any other fields in the document, it'll be much more categorized:
{
categories: {
category_1: {
name: "",
type: ""
},
category_2: {
name: "",
type: ""
}
}
}
However, creating a sub-collection could be better choice as well if each category gets more data in future and you need some queries on categories.
users -> {userId} -> categories -> {categoryId}
(col) (doc) (col) (doc)
As the categories are in a sub-collection you don't need to add userId field in every category document. However you can also create 2 different root collections namely - users and categories:
users -> {userId}
(col) (doc)
categories -> {categoryId}
(col) (doc)
In this case you would have to store userId field to filter between owner of those categories.
#Alex Mamo has perfectly explained difference between using a root level collection and a sub-collection in this post if you need an in-depth explanation: What are the benefits of using a root collection in Firestore vs. a subcollection?
Security rules will be different in both the cases. If you use sub-collections then you can just read user UID from the wildcard like this:
match /users/{userId}/categories/{categoryId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
However, if you use a root level collection, the document ID for each category usually won't have user's UID but the UID will be stored in the document as a field. In that case you would have to read the userId from the document being requested.
match /categories/{categoryId} {
allow read, write: if request.auth != null && request.auth.uid == resource.data.userId;
}
what worries me here is that I need to somehow invent these names of the maps
NoSQL database don't have a schema. You can dynamically add any key as required. Just treat every document as a JSON object.
const newKey = "category_123"
const newValue = {name: "newCatName", type: "expense"}
const userDoc = firebase.firestore().collection("users").doc("userId")
userDoc.update({
[`categories.${newKey}`]: newValue
})
This update operation will create a new child in 'categories' map with the provided values.

Related

Firestore rules validate query field value

I have collection of users and every user can search for others by their publicId
query(
collection(db, "users"),
where("publicId", "==", "..."),
limit(1)
);
and I want to allow users to regenerate their `publicId"s so others won't be able to find them by the old ones.
The problem is that if someone finds a user once and get their doc id they could potentially get the user by that doc("users", "docId") regardless of their "publicId".
I tried to use request.query.publicId == resource.data.publicId, but query seems to only provide limit, offset and orderBy.
Is there a different way to access the query field value or a different way to mitigate the issue?
For the public profile, it might be best to create another collection e.g. "public_users" where the document ID is user's publicId. So when a user regenerates their ID, you can just create another document with new publicId and then delete the previous one.
Do not store a reference to user's UID in this document if you want to keep that a secret. Instead, store this public ID in the "users" collection so that can be read by user only.
Alternatively, you can make your requests through a Cloud Function and block direct requests to Firestore. So there's no way anyone can query by user ID.
For the main collection, you can add a rule that allows users to write their own document only like this:
match /users/{userId} {
allow write: if request.auth.uid == userId;
}

Filter/some on list of objects in a Firestore security rule

In a Firestore security rule, I'm trying to check if the user attempting to create/delete a document in one collection is also the owner of that object, as marked in another collection. Basically, my data looks something like this:
In the users collection, each user has a document like this:
{
name: 'john',
userItems: [
{
id: 'random-id',
...
},
...
],
...
}
In the items collection (which I am writing the rule for), all of the items from all of the users of the platform are there, and have Firestore IDs which correspond to the id keys in the elements of the items list of their owners. So if john created an item with the id random-id, his user document would look like the above, and there would be a new document in the items collection with the Firestore ID of random-id.
What I am trying to achieve is to create a security rule wherein a document in the items collection can only be updated if the user document of the currently authed user, which I can access with get(/databases/$(database)/documents/users/$(request.auth.uid)), has an element in their userItems list which has the id key equal to request.resource.id. If this were normal JS, I'd probably do something like:
match /items/{item} {
allow write: if get(/databases/$(database)/documents/users/$(request.auth.uid))
.data
.userItems
.some(userItem =>
userItem.id === request.resource.id
)
}
However, the Firestore List interface is very lacklustre, and doesn't support fancy operations like .some, or even basic/manual looping (as far as I'm aware). I've been trying to come up with some clever way around this, but even .joining the list and .matching the resulting string with some fancy RegExp wouldn't work, since I'm pretty sure that maps would parse as '[object Object]' instead of stringifying properly.
Is there any way to do this using standard Firestore rules, without reconfiguring my DB structure?
What you're trying to do isn't possible with security rules. You will need to either change that way you represent your data (which I recommend, as a list is probably not the best representation), or add more data to satisfy your requirements.
If the the random-id is unique in the list, you should consider using a map instead of a list to represent it, so that you can do simple lookups on the Map that becomes available in rules. If your userItems field was a map indexed by that ID, you could instead say:
allow write: if get(...).data.userItems.keys().hasAny([request.resource.id]);
If for some reason you can't change the field, you will need to duplicate the IDs into a new list field and check it like this:
allow write: if get(...).data.userItemIds.hasAny([request.resource.id]);

Is it possible to fetch all documents whose sub-collection contains a specific document ID?

I am trying to fetch all documents whose sub-collection contain a specific document ID. Is there any way to do this?
For example, if the boxed document under 'enquiries' sub-collection exists, then I need the boxed document ID from 'books' collection. I couldn't figure out how to go backwards to get the parent document ID.
I make the assumption that all the sub-collections have the same name, i.e. enquiries. Then, you could do as follows:
Add a field docId in your enquiries document that contains the document ID.
Execute a Collection Group query in order to get all the documents with the desired docId value (Firestore.instance.collectionGroup("enquiries").where("docId", isEqualTo: "ykXB...").getDocuments()).
Then, you loop over the results of the query and for each DocumentReference you call twice the parent() methods (first time you will get the CollectionReference and second time you will get the DocumentReference of the parent document).
You just have to use the id property and you are done.
Try the following:
Firestore.instance.collection("books").where("author", isEqualTo: "Arumugam").getDocuments().then((value) {
value.documents.forEach((result) {
var id = result.documentID;
Firestore.instance.collection("books").document(id).collection("enquiries").getDocuments().then((querySnapshot) {
querySnapshot.documents.forEach((result) {
print(result.data);
});
First you need to retrieve the id under the books collection, to be able to do that you have to do a query for example where("author", isEqualTo: "Arumugam"). After retrieving the id you can then do a query to retrieve the documents inside the collection enquiries
For example, if the boxed document under 'enquiries' sub-collection exists, then I need the boxed document ID from 'books' collection.
There is no way you can do that in a single go.
I couldn't figure out how to go backwards to get the parent document ID.
There is no going back in Firestore as you probably were thinking. In Firebase Realtime Database we have a method named getParent(), which does exactly what you want but in Firestore we don't.
Queries in Firestore are shallow, meaning that it only get items from the collection that the query is run against. Firestore doesn't support queries across different collections in one go. A single query may only use the properties of documents in a single collection. So the solution to solving your problem is to perform two get() calls. The first one would be to check that document for existence in the enquiries subcollection, and if it exists, simply create another get() call to get the document from the books collection.
Renaud Tarnec's answer is great for fetching the IDs of the relevant books.
If you need to fetch more than the ID, there is a trick you could use in some scenarios. I imagine your goal is to show some sort of an index of all books associated with a particular enquiry ID. If the data you'd like to show in that index is not too long (can be serialized in less than 1500 bytes) and if it is not changing frequently, you could try to use the document ID as the placeholder for that data.
For example, let's say you wanted to display a list of book titles and authors corresponding to some enquiryId. You could create the book ID in the collection with something like so:
// Assuming admin SDK
const bookId = nanoid();
const author = 'Brandon Sanderson';
const title = 'Mistborn: The Final Empire';
// If title + author are not unique, you could add the bookId to the array
const uniquePayloadKey = Buffer.from(JSON.stringify([author, title])).toString('base64url');
booksColRef.doc(uniquePayloadKey).set({ bookId })
booksColRef.doc(uniquePayloadKey).collection('enquiries').doc(enquiryId).set({ enquiryId })
Then, after running the collection group query per Renaud Tarnec's answer, you could extract that serialized information with a regexp on the path, and deserialize. E.g.:
// Assuming Web 9 SDK
const books = query(collectionGroup(db, 'enquiries'), where('enquiryId', '==', enquiryId));
return getDocs(books).then(snapshot => {
const data = []
snapshot.forEach(doc => {
const payload = doc.ref.path.match(/books\/(.*)\/enquiries/)[1];
const [author, title] = JSON.parse(atob(details));
data.push({ author, title })
});
return data;
});
The "store payload in ID" trick can be used only to present some basic information for your child-driven search results. If your book document has a lot of information you'd like to display once the user clicks on one of the books returned by the enquiry, you may want to store this in separate documents whose IDs are the real bookIds. The bookId field added under the unique payload key allows such lookups when necessary.
You can reuse the same data structure for returning book results from different starting points, not just enquiries, without duplicating this structure. If you stored many authors per book, for example, you could add an authors sub-collection to search by. As long as the information you want to display in the resulting index page is the same and can be serialized within the 1500-byte limit, you should be good.
The (quite substantial) downside of this approach is that it is not possible to rename document IDs in Firestore. If some of the details in the payload change (e.g. an admin fixes a book titles), you will need to create all the sub-collections under it and delete the old data. This can be quite costly - at least 1 read, 1 write, and 1 delete for every document in every sub-collection. So keep in mind it may not be pragmatic for fast changing data.
The 1500-byte limit for key names is documented in Usage and Limits.
If you are concerned about potential hotspots this can generate per Best Practices for Cloud Firestore, I imagine that adding the bookId as a prefix to the uniquePayloadKey (with a delimiter that allows you to throw it away) would do the trick - but I am not certain.

How do I securely limit users to only list documents that they've created?

I want to be able to limit users to only list documents they've created.
The user id is stored in the user field
Obviously I can do
db.collection('projects').where('user', '==', firebase.auth().currentUser.uid)
.. but any tech savvy user could just remove the filter and get everything.
I've limited access in rules like
match /projects/{project} {
allow read,update: if request.auth.uid == resource.data.user;
allow create;
}
But this doesn't work, you can't list at all.
Is there a way of doing this without creating a subcollection of the user's entry in the user collection? I'd really prefer to have them all in one place.
Surely this is an extremely common scenario.
Assuming you stored the user_id in the field "user" in firestore. You can use
String uid = FirebaseAuth.getInstance().getCurrentUser().getUid();
db.collection("projects").whereEqualTo("user", uid)...
After ellipses you can use .get() with onSuccesss or onComplete or add Snapshot listener.

How to enforce values in a map in Firestore rules without knowing the keys?

I'm trying to implement something similar to "Working with Arrays, Lists, and Sets" example in Firestore's document, but for user access control list.
In a document, there'll be a modified field of a timestamp, and a users field of a map, containing user's UID as a key and the modified timestamp as value (so that I can sort using this field).
{
modified: 2018-01-01T17:05:00Z,
users: {
someUID: 2018-01-01T17:05:00Z,
otherUID: 2018-01-01T17:05:00Z
/* ... */
}
}
It's obvious that I have to keep the values inside users map in sync with the modified field. As I don't want to pay a cloud function call and a document write whenever the document is updated, I plan to do the update on the client, at the same time the document itself is updated.
The question is, how can I enforce, using Firestore security rules, the values of users map so that it'll always be in sync with the modified field? The keys of this map isn't known in advance, and the size of this map can be variable, too.
You can use writeFields for update rules if map is variable or keys are unknown while updating.
allow update: if request.auth != null
&& request.resource.data.users is map
&& request.writeFields.size() == 1
&& (('users.' + request.auth.uid) in request.writeFields)
&& request.resource.data.users[request.auth.uid] <= request.time.time();

Resources