Firestore Rules to update 2 arrays with Batch Write - firebase

I am doing a batch write like so:
const batch = this.afs.firestore.batch();
const studentID = this.afs.createId();
const classID = this.afs.createId();
const studentRef = this.afs.doc(`students/${studentID}`).ref;
batch.set(studentRef, {
name: 'tom',
classes: firebase.firestore.FieldValue.arrayUnion(classID)
});
const classRef = this.afs.doc(`classes/${classID}`).ref;
batch.set(classRef, {
name: 'calculus',
students: firebase.firestore.FieldValue.arrayUnion(studentID)
});
await batch.commit();
And I want to ensure that either:
students/studentID/classes array can only be changed if classes/classID/students array also changed with the correct IDs
this is not enforced if that field is not updated
one field cannot be deleted unless the other field is deleted
So, I am thinking this:
match /students/{studentID} {
allow read;
allow write: if noChange('classes') ||
matchWrite(studentID, 'classes', 'students');
}
match /classes/{classID} {
allow read;
allow write: if noChange('students') ||
matchWrite(classID, 'students', 'classes');
}
function noChange(field) {
return !(field in request.writeFields);
}
function getVal(field) {
return resource.data[field].removeAll(request.resource.data[field])[0];
}
function matchWrite(VAL1, VAL2, VAL3) {
return VAL1 in
getAfter(/databases/$(database)/documents/$(VAL2)/$(getVal(VAL2))).data[VAL3];
}
delete - delete for references...
// allow delete: if noChange('classes') || matchDelete('classes', 'students');
// allow delete: if noChange('students') || matchDelete('students', 'classes');
// function matchDelete(VAL1, VAL2) {
// students/STUDENTID/classes array CLASSID (being removed)
// must eq classes/CLASSID/students array STUDENTID (being removed)
// and the other way around
// }
I am getting boggled down on the last part. Since the batches are atomic, I would think I can use getAfter() somehow.
How would I ensure atomic rules or nothing?
J

Related

how to write Firestore security rules to also allow null value and undefined value in a single field?

I have a field in my Firestore document called lastApproval that should be a timestamp if it has a value.
this is my simplified rules
match /users/{userID} {
allow create: if isValidUserStructure(incomingData())
allow update: if isValidUserStructure(incomingData())
}
function incomingData() {
return request.resource.data;
}
function isValidUserStructure(user) {
return user.email is string
&& user.fullname is string
&& user.lastApproval is timestamp // error in here
}
as you can see, isValidUserStructure function will be used to check when creating and updating user document.
when creating user document, that lastApproval field will be null like this
const data = {
fullname: 'John Doe',
email: 'my#email.com',
lastApproval: null
};
await db.collection('users').doc('userIDHere').set(data);
but when updating document, I only want timestamp.
const data = {
lastApproval: new Date()
};
await db.collection('users').doc('userIDHere').update(data);
and also, I want to pass the security rules if lastApproval is not available, for example, if the user only want to update the fullname like this
const data = {
fullname: "My New Name"
};
await db.collection('users').doc('userIDHere').update(data);
so I want my security rules to accept timestamp, null and undefined value for lastApproval field
I have tried but error
please help :)
There isn't any type null as far as I know. You can find all available types in the documentation.
If you want to check if the value is null then try user.lastApproval == null instead. To check if the lastApproval property exists at first place, try this:
match /collection/{doc} {
allow write: if 'lastApproval' in request.resource.data && (..otherLogic)
}
So you can write your function as:
function isValidUserStructure(user) {
return user.email is string
&& user.fullname is string
&& (('lastApproval' in user && user.lastApproval is timestamp) || !('lastApproval' in user))
}

Create documents in different firestore collections, with same reference ID

My question is actually twofold, so I m not sure I should ask both in one post or create another post. Anyway, here it is:
I am creating users in firestore database. I do not want to put all details in a single document because it will be requested a lot, and all details will be retrieved, even if not needed. So I decided to create a collection members_full with all details of users I may not need often, and another collection called members_header to keep the few most important details. On creation of a new user, I want reference ID in both collections to be the same for a specific user.
- members_full -+
|
+ --- abnGMbre --- +
|
+ --- mother : 'His mom'
+ --- Father: 'daddy'
- members_header+
|
+ ---- abnGMbre -- +
|
+ ---- fullname: 'john Doe'
+ ---- pictURL: 'path to his profile pic'
I want something looking like the above.
So this is what I did in the cloud function:
/** Create / Update a member
* ------------------------- */
exports.updateMember = functions.https.onCall( (data, context) =>{
// root member and secretaries are allowed to update members
const authParams:any = {
uid: context.auth.uid,
email: context.auth.token.email,
};
// Check if user is allowed to perform operation
return checkPermission(authParams, ['root', 'secretary']).then(res => {
if(res==false){
return { // Permission denied
status: STATUS.permission_denied,
}
}
// set object to add/ update
const member:any = data;
// Check if uid of member object is present (true:update, false: create)
var fullRef : admin.firestore.DocumentReference;
var headRef : admin.firestore.DocumentReference;
var countRef: admin.firestore.DocumentReference;
var createNewMember = false;
if(member.uid!==undefined && member.uid!==null){ // update
fullRef = fsDB.collection('members_full').doc(member.uid);
headRef = fsDB.collection('members_header').doc(member.uid);
} else {
fullRef = fsDB.collection('members_full').doc();
headRef = fsDB.collection('members_header').doc(fullRef.id);
countRef = fsDB.collection('counters').doc('members');
createNewMember = true;
}
return fsDB.runTransaction(t => {
return t.get(fullRef).then(doc => {
// Update full details
t.set(fullRef, {
surname : member.surname ,
firstName : member.firstName ,
birthDate : member.birthDate ,
birthPlace : member.birthPlace ,
email : member.email ,
phone : member.phone ,
occupation : member.occupation ,
father : member.father ,
mother : member.mother ,
spouse : member.spouse ,
children : member.children ,
addressHome : member.addressHome ,
addressLocal: member.addressLocal,
contactHome : member.contactHome ,
contactLocal: member.contactLocal,
comment : member.comment ,
regDate : member.regDate ,
});
// Update header details
t.set(headRef, {
fullName : member.fullName ,
gender : member.gender ,
active : member.active ,
picURL : member.picURL ,
});
// Increment number of members
if(createNewMember ){
t.update(countRef, {count: admin.firestore.FieldValue.increment(1)});
}
}).then(() => {
return { status : STATUS.ok }
}).catch(err => {
return {
status: STATUS.fail,
message: err.message,
error: err
}
});
}).then(() => {
return { status : STATUS.ok }
}).catch(error =>{
return {
status: STATUS.fail,
message: error.message,
debug: 'run transaction err',
error: error
}
});
}).catch(err => {
return {
status: STATUS.fail,
message: err.message,
debug: 'check permission err',
error: err
}
});
});
/** Check if authenticated user's roles are among the ones allowed
* --------------------------------------------------------------- */
function checkPermission(authParams:any, allowedRoles:any[]):Promise<boolean>{
// Check if authenticated user as any of the roles in array 'allowedRoles'
return new Promise((resolve, reject) => {
// If one of allowed roles is root, check against global variables
if(allowedRoles.indexOf('root')>=0 &&
( root_auth.email.localeCompare(authParams.email)==0 ||
root_auth.uid.localeCompare(authParams.uid)==0)){
resolve(true);
}
// Get autID
const uid = authParams.uid;
// Get corresponding user in collection roles
admin.firestore().collection('userRoles').doc(uid).get().then(snap => {
// Get roles of user and compare against all roles in array 'allowedRoles'
const memRoles = snap.data().roles;
var found = false;
var zz = memRoles.length;
for(let z=0; z<zz; z++){
if(allowedRoles.indexOf(memRoles[z])){
found = true;
break;
}
}
resolve(found);
}).catch(err => {
reject(err);
});
});
}
When I call this cloud function, it only writes in document members_full, and increment number of members. It does not create entry in members_header.
My first question: where did I go wrong? the way I' m getting ID from the first document to create second document, isn't it valid?
The second question, will it be better to create subcollections rather than having 2 collections? if yes, how to do I do that in a transaction?
Help much appreciated
You need to chain the method calls in the Transaction. It is not extremely clear in the documentation, but if you look at the reference document for a Transaction (https://firebase.google.com/docs/reference/node/firebase.firestore.Transaction) you will see that the update() and set() methods return a Transaction, which is
the "Transaction instance. [and is] used for chaining method calls".
So you should adapt your code along these lines:
return fsDB.runTransaction(t => {
return t.get(fullRef)
.then(doc => {
t.set(fullRef, {
surname : member.surname ,
firstName : member.firstName
//....
})
.set(headRef, {
//....
gender : member.gender
//....
})
.update(countRef, {count: admin.firestore.FieldValue.increment(1)});
});
});
You also need to correctly chain all the different promises, as follows:
return checkPermission(authParams, ['root', 'secretary'])
.then(res => {
//...
return fsDB.runTransaction(t => {
//.....
});
.then(t => {
return { status : STATUS.ok }
})
.catch(error => {...})
However, you may use a batched write instead of a transaction, since it appears that you don't use the document returned by t.get(fullRef) in the transaction.
For your second question, IMHO there is no reason to use sub-collections instead of two (root) collections.

Firebase role based access fails for new users

I've followed Secure data access for user groups on the firebase website:
https://firebase.google.com/docs/firestore/solutions/role-based-access
Now my roles are as followed:
service cloud.firestore {
// Roles admin = 0, owner = 1, writer = 2, reader = 3
match /databases/{database}/documents {
function isSignedIn() {
return request.auth != null;
}
function isAdmin() {
return isSignedIn() && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == 0
}
match /projects/{project} {
function getRole(rsc) {
return rsc.data.roles[request.auth.uid];
}
function isOneOfRoles(rsc, array) {
return isSignedIn() && (getRole(rsc) in array);
}
function canRead() {
return isAdmin() || isOneOfRoles(resource, [1, 2, 3])
}
function canWrite() {
return isAdmin() || isOneOfRoles(resource, [1, 2])
}
allow read: if canRead();
allow write: if canWrite();
}
}
}
Now when a new user is created he isn't an admin and he doesn't have projects.
So actually he should be able to create a new project and his list of project should be empty.
I'm doing this query to retrieve all the projects:
getAllProjectsForUser(): Observable<Project[]> {
return from(this.fireStore.collection('projects').snapshotChanges().pipe(map((data => {
const projects: Project[] = [];
data.forEach((doc) => {
projects.push(new Project(doc.payload.doc.id, doc.payload.doc.data()['projectName'], doc.payload.doc.data()['companyName']));
});
return projects;
}))));
}
Now everything works when I'm an admin but it doesn't work for people who don't have any projects and are not admin.
I always get Missing or insufficient permissions.
When I run a query in the rules simulator it gives me:
Error: simulator.rules line [16], column [18]. Null value error. Which is on this line:
return rsc.data.roles[request.auth.uid];
I have no idea how I can solve this?
I tried changing the query like this:
getAllProjectsForUser(uuid: string): Observable<Project[]> {
return from(this.fireStore.collection('projects').ref.where('roles.' + uuid, '<', 4).get()).pipe(map((data => {
const projects: Project[] = [];
data.forEach((doc) => {
projects.push(new Project(doc.id, doc.data()['projectName'], doc.data()['companyName']));
});
return projects;
})));
}
but this ofcourse gives the same problem because there is no project with a roles array.
EDIT
I found out that when I test the rules in the firebase simulator that it works.
It's just the query to retrieve all my projects where I'm owner|writer|reader that doesn't
screenshot data:

Firestore transactions with security rules making reads

I want to create two documents
Account/{uid} {
consumerId: ... //client generated id
}
Consumer/{consumerId} {
...
}
and I have a security rule for the consumer collection
match /Consumer/{consumerId} {
allow create: if (consumerId == get(/databases/$(database)/documents/Account/$(request.auth.uid)).data['consumerId'];
}
I need to ensure that an account can only add a consumer document with a consumerId corresponding to the one in their Account document. Both documents should be created together. I've been trying to do this with transactions but I keep getting the error "Transaction failed all retries.". Whats going wrong and how do I fix it?
The data variable is an object and not an array, so you should use data.consumerId instead of data['consumerId']:
match /Consumer/{consumerId} {
allow create: if consumerId == get(/databases/$(database)/documents/Account/$(request.auth.uid)).data.consumerId;
}
I ended up accomplishing this with a batch write and security rules.
match /consumer/{cid} {
function isNewResource() { return resource == null; }
allow create: if isRegistered();
allow read, update: if isNewResource();
}
And then client side with something along the lines of
createThing() {
const db = firebase.firestore();
const { uid, displayName } = this.auth.currentUser;
const batch = this.db.batch();
// Essentially generating a uuid
const newConsumerRef = db.collection("consumer").doc();
// Update the user doc
batch.update(
db.collection('/users').doc(uid),
{ consumerID: newConsuemrRef.id }
);
// Update the admins field in the new doc
batch.set(newConsumerRef, {
admins: {
[uid]: displayName,
},
});
return batch.commit();
}
My problem was the same, but the write to the field in the collections actually needed to be to an object key, so it looked a little funkier
batch.update(
db.collection('/users').doc(uid),
{ [`adminOf.${newRef.id}`]: 'some special name' }
);

How to define an contains using firebase-bolt?

I'm using firebase-bolt to set my rules.
My bolt:
// ######## CONTENTS
path /contents {
read() = true;
index() = ["dt_created"];
}
path /contents/$id is Timestamped<Contents> {
write() = isSignedIn() && isAllowEdit(this);
}
type Contents {
text : String,
address : String,
organization: String | Null,
location: String | Null,
map_lat : String,
map_lng : String,
num_favorite: Number,
num_comment: Number,
num_denounce: Number,
removed: Boolean,
category_id : String,
user_id : String,
photos: String[]
}
//
// Helper Functions
//
isSignedIn() = auth != null;
isAllowEdit(value) = (prior(value) == null || newData.child('user_id').val() == auth.uid);
I would like the only the owner the post can edit, but anyone can update the counters.
I think :
"contents": {
"$id": {
"num_favorite": {
".write": true
....
Not sure if it's possible. But can I create rules to edit only field with contains ?
This in regular Firebase Security Rules:
"contents": {
"$id": {
"num_favorite": {
".write": true
}
Translates to this on Bolt:
path /contents/$id/num_favorite {
write() = true;
}
This will work, because you're adding a new permission and not trying to remove an existing permission (which is not possible in Firebase's security rules language).
But I would consider separating the vote out into its own higher-level node:
path /favorite_counts/$id {
write() = true;
}
This keeps your security rules simpler and more isolated from each other.

Resources