Firestore security rule: Null value error when using Set.hasAny() - firebase

I have a user collection with two fields memberOf and managerOf (that is, of an organisation; both are arrays of doc id).
I would like to restrict a manager to list only users that are members of an organisation they managed.
In JS, it would be something like this:
const memberOf = [1, 2, 3, 4, 5]
const managerOf = [6, 7, 1, 9, 0]
console.log(memberOf.some(el => managerOf.includes(el))) // 👈 returns true
This is what I have so far:
function isSignedIn() {
return request.auth.uid != null
}
function isAdmin() {
return isSignedIn() && 'admin' in request.auth.token && request.auth.token.admin
}
match /users/{userId} {
allow get: if isSignedIn() && (request.auth.uid == userId || isAdmin());
allow list: if isAdmin() || ???; // 👈 how can I express the above condition?
allow write: if isAdmin();
}
And that's the query:
const unsubscribe = db.collection('users')
.where('memberOf', 'array-contains', organisationId)
.orderBy('email', 'asc')
.onSnapshot(snap => {
console.log(`Received query snapshot of size ${snap.size}`)
var docs = []
snap.forEach(doc => docs.push({ ...doc.data(), id: doc.id }))
actions.setMembers(docs)
}, error => console.error(error))
First, I wanted to use the organisationId from the request in the security rule, but it's not available as it's not a write operation (https://firebase.google.com/docs/reference/rules/rules.firestore.Request#resource)
I thought about:
function hasMemberManagerRelationship(userId) {
return isSignedIn() && get(/databases/$(database)/documents/users/$(userId)).data.memberOf in get(/databases/$(database)/documents/users/$(request.auth.uid)).data.managerOf
}
match /users/{userId} {
allow get: if isSignedIn() && (request.auth.uid == userId || isAdmin());
allow list: if isAdmin() || hasMemberManagerRelationship(userId);
allow write: if isAdmin();
}
or
function hasMemberManagerRelationship(userId) {
return isSignedIn() && get(/databases/$(database)/documents/users/$(userId)).data.memberOf.toSet().hasAny(get(/databases/$(database)/documents/users/$(request.auth.uid)).data.managerOf.toSet())
}
(https://firebase.google.com/docs/reference/rules/rules.Set#hasAny)
But it's not working and I have the error FirebaseError: Null value error. for 'list' # L27. AND on top of that, that could generate a lot of extra read operations (not billing-wise optimised).
I could do something like the following:
allow list: if isAdmin() || (isManagerOf('jJXLKq7p9wWSNLsHcVIn') && 'jJXLKq7p9wWSNLsHcVIn' in resource.data.memberOf);
where jJXLKq7p9wWSNLsHcVIn is the id of an organisation (and used in the query), but I don't know how I can retrieve the id from the request "context"..
Any help would be appreciated!

Ok. First, thank you #Doug Stevenson for mentioning debug() in another post! I didn't know it exists, and it rocks!
The result of debug(resource.data.memberOf) in the debug log was:
constraint_value {
simple_constraints {
comparator: LIST_CONTAINS
value {
string_value: "jJXLKq7p9wWSNLsHcVIn"
}
}
}
LIST_CONTAINS forced me to have a look at List: https://firebase.google.com/docs/reference/rules/rules.List#hasAny
toSet() does not apply to a list, but a list has already the hasAny() function.
(in fact, it does exist but it didn't work in my case 🤔)
In the end, this rule works:
function hasMemberManagerRelationship() {
return isSignedIn() && resource.data.memberOf.hasAny(getUser(request.auth.uid).data.managerOf)
}
Now I'm just wondering if getUser(request.auth.uid).data.managerOf is somehow cached (1 read for multiple user entries) or re-run every time (100 users, 100 extra reads).
Any thoughts on that?
I sincerely hope this is the first case ^^

I tested rules which are pretty similar to your attempt using the firestore "Rules Playground" and it seems to be working.
You do not need to get() the current userId because you already have it in the resource object.
I am not sure it would generate a lot more read operations because we are only using get() for request.auth.uid.
function getUser(userId) {
return get(/databases/$(database)/documents/users/$(userId));
}
function isSignedIn() {
return request.auth.uid != null;
}
function isAdmin() {
return isSignedIn() && 'admin' in request.auth.token && request.auth.token.admin;
}
function hasMemberManagerRelationship() {
return isSignedIn() && resource.data.memberOf.toSet().hasAny(getUser(request.auth.uid).data.managerOf.toSet());
}
match /users/{userId} {
allow read: if isAdmin() || hasMemberManagerRelationship();
}
Where exactly are you getting the FirebaseError: Null value error?

Related

How do I access the document ID in firestore rules

I am seemingly unable to access the resource.id value when trying queries using these rules. when I manually enter the schools id (the commented out line) the data returns fine. I only have 1 school and the doc ID definitely matches the string. but when I ask to match to the resource.id value, my rules return an 'insufficient permissions' error.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
//functions
function signedIn() {
return request.auth.uid != null;
}
function returnUID(){
return request.auth.uid;
}
function getUserData() {
return get(/databases/$(database)/documents/All%20Users/$(request.auth.uid)).data;
}
match /All%20Users/{userID} {
allow read,write: if
signedIn() && returnUID() == userID;
}
match /All%20Schools/{schoolID}{
allow read, write: if
// signedIn() && getUserData().school == "f7asMxUvTs3uFhE08AJr"
signedIn() && getUserData().school == resource.id
}
}
}
my structure is like this
All Schools / school (document) / Classrooms (subcollection)
All Users / User (document) (each user doc has a classroomID associated to it)
as a point of reference this is a query that is successful
var docRef = db.collection("All Users").doc(uid).get()
and the one that is failing
db.collection("All Schools/" + properties.schoolid + "/Classrooms").onSnapshot()
[update]
the working set of rules!
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
//functions
function signedIn() {
return request.auth.uid != null;
}
function returnUID(){
return request.auth.uid;
}
function getUserData() {
return get(/databases/$(database)/documents/All%20Users/$(request.auth.uid)).data;
}
match /All%20Users/{userID} {
allow read,write: if
signedIn() && returnUID() == userID;
}
match /All%20Schools/{schoolID}{
allow read, write: if schoolID == 'f7asMxUvTs3uFhE08AJr'
}
match /All%20Schools/{schoolID}/Classrooms/{classId} {
allow read, write: if getUserData().school == schoolID;
}
match /All%20Schools/{schoolID}/Student%20List/{student} {
allow read, write: if getUserData().school == schoolID;
}
match /All%20Schools/{schoolID}/Staff/{staff} {
allow read, write: if getUserData().school == schoolID;
}
}
}
The following rules will be effective on documents of 'All Schools' collection only and not documents of 'Classrooms' sub-collection:
match /All%20Schools/{schoolID} {
// ...
}
That's why db.collection("All Users").doc(uid).get() works and fetching 'Classrooms' collection fail since you do not have any rules specified for it. Although you had a recursive wildcard earlier (before editing the question), resource object contains data of those documents being matched in 'Classrooms' sub-collection and hence getUserData().school == resource.id failed too.
That being said, try specifying rules for 'Classrooms' sub-collection as well:
match /All%20Schools/{schoolID}/Classrooms/{classId} {
allow read, write: if getUserData().school == schoolID;
}
match /All%20Schools/{schoolID}/Classrooms/{classID} {
// schoolID is the documentId
allow read, write: if signedIn() && getUserData().school == schoolID
}
If this was my code, I would not use spaces in my collection or field names. Rather I will use snake_case or camelCase.
So instead of All Schools, I will use either all_schools or allSchools.

Missing or Insufficient permissions on Firestore Security Rule with get()

Pretty much no matter what I use for the get() request, getting Missing or insufficent permissions when logged in with a userID that is a "member":
function isSelf(userID) {
return request.auth != null && request.auth.uid != null && request.auth.uid == userID
}
function isMember(userID) {
return request.auth != null && request.auth.uid != null && get(/databases/$(database)/documents/'members'/$(request.auth.uid)).data.parent == userID
}
match /templates/{userID} {
allow read, write: if false
match /templates/{templateID} {
allow read: if isSelf(userID) || isMember(userID)
allow write: if isSelf(userID)
allow delete: if false
}
allow read: if isSelf(userID) || isMember(userID)
allow write: if isSelf(userID)
}
Have tried using get() with .data.parent and with .parent The member doc looks like this:
{
parent: 'USER_ID_OF_PARENT'
}
Call from the client app is:
export const getTemplate = async ({ userID, form }) => {
db.collection('templates').doc(userID).collection('templates').doc(form).get()
.then((doc) => {
})
.catch((err) => {
console.error(err)
})
}
Database structure is:
/templates/{userID} is a collection of docs with ids as userIDs that correspond to a doc with matching userID in /users/{userID}
/members/{memberID} is a collection of docs with ids as memberIDs, with a parent field with a string value SOME_USER_ID which matches a doc with userID SOME_USER_ID in /users/{userID}
Example:
/members/'MEMBER_1' doc:
{
name: 'Member 1',
parent: 'OWNING_USER_1'
}
/users/'OWNING_USER_1' doc:
{
name: 'Owning User 1',
parent: 'OWNING_USER_1'
}
/templates/'OWNING_USER_1' doc:
{
// no fields
}
/templates/'OWNING_USER_1'/templates/'FORM_1' doc:
{
name: 'Form 1'
}
With the following call:
getTemplate({
userID: 'OWNING_USER_1',
form: 'FORM_1'
})
When the authenticated user is OWNING_USER_1, the above call is successful (the isSelf() rule returns as true) and the found template document is returned
When the authenticated user is MEMBER_1, the above call gets Missing or insufficient permissions (the isMember() rule returns false)
Removed the quotes from around 'members' and this is now working correctly:
Replaced:
get(/databases/$(database)/documents/'members'/$(request.auth.uid)).data.parent
with:
get(/databases/$(database)/documents/members/$(request.auth.uid)).data.parent

query permissions for custom token

I have a custom token that contains a payload of appid to identify third party apps.
I'd like to verify reads/writes to my data such that:
the user is signed in (there is a valid uid / token)
the appid is registered in the /apps collection
the uid and appid are fields in the record and match the credentials
(unrelated to this question, but to be complete) this record matches the schema for the document.
current best solution and remaining questions
This answer, that I eventually stumbled upon, is pretty good, but it might be improved.
The first thing I had to do was correctly identify the payload I was giving in the custom token -- because I am using a cloud service function to generate the payload (graphcool), my payload was default: { appid } instead of just appid. From there, just some rewording on the permissions was enough to succeessdully validate using the current rules I'd imagined:
service cloud.firestore {
match /databases/{database}/documents {
match /sampleData/{type}/{appName}/{record} {
allow read: if isSignedIn() && isValidApp(database) && ownsExisting() && appIdInExisting()
allow write: if isSignedIn() && isValidApp(database) && ownsPayload() && appIdInPayload()
}
// functions
function isSignedIn () {
return request.auth != null
}
function isValidApp (database) {
return exists(/databases/$(database)/documents/apps/$(request.auth.token.appid))
}
function ownsExisting () {
return resource.data.uid == request.auth.uid
}
function ownsPayload () {
return request.resource.data.uid == request.auth.uid
}
function appIdInExisting () {
return resource.data.app_id == request.auth.token.appid
}
function appIdInPayload () {
return request.resource.data.app_id == request.auth.token.appid
}
}
}
Someone could do better though.
is there any way to validate the appid without using an exists request (And without writing a if-else if chain) -- like with an array directly in the rules perhaps?
how can I ensure that the appid specified in the payload matches the one I envisioned when I issued the service credentials to the client third-party-app?
edit: original question
I thought I could get at least the first two myself:
service cloud.firestore {
match /databases/{database}/documents {
match /sampleData {
allow read: if isSignedIn() && isValidApp() && ownsExisting() && appIdInExisting()
allow write: if isSignedIn() && isValidApp() && ownsPayload() && appIdInPayload()
}
// functions
function isSignedIn () {
return request.auth != null
}
function isValidApp () {
return get(path('apps')).data.child(request.auth.token.appid).exists()
}
function ownsExisting () {
return resource.data.uid == request.auth.uid
}
function ownsPayload () {
return request.resource.data.uid == request.auth.uid
}
function appIdInExisting () {
return resource.data.app_id == request.auth.token.appid
}
function appIdInPayload () {
return request.resource.data.app_id == request.auth.token.appid
}
}
}
/apps has 1 document called "sample-app-id" with id and name fields of "sample-app-id" ... but using this in my token does not work: FirebaseError: Missing or insufficient permissions
I am generating the token via this function on my server:
var FirebaseAdmin = require('firebase-admin')
var serviceAccount = require('./firebase-service-credentials.json')
var claims = require('./custom-token-claims') // {appid: 'sample-app-id'}
let credential = FirebaseAdmin.credential.cert(serviceAccount)
FirebaseAdmin.initializeApp({ credential })
const generateTokenWithPayload = async id => {
try {
const token = await FirebaseAdmin.auth().createCustomToken(id, claims)
return { data: { token } }
} catch (err) {
return { error }
}
}
module.exports = async event =>
await generateTokenWithPayload(event.data.userIdentifier)
and before posting I am signing in -- this part I can verify seems to be working as I see the new, non-anonymous user in my Authentication -> Users tab in the firebase console:
— Feb 11, 2019 Feb 11, 2019 smaple-user-id
Here's essentially the client code:
await firebase
.auth()
.signInWithCustomToken(token)
.catch(console.error)
const db = await firebase.firestore()
db.collection(path + this.state.appName).add(payload)
I am posting a record with the schema {app_id, app_name, date, metric, uid} to sampleData/metrics/sample-app-name/{auto-generated}
notes:
the number of apps that are going to be registered is small -- it would probably make sense from a financial perspective to make this just a static array in the permissions file, if that is possible, rather than a get request.
big improvement - I just noticed that request.auth.token.appid should be request.auth.token.default.appid because I was using export defaultinstead of modules.export
This answer is pretty good, but it might be improved.
The first thing I had to do was correctly identify the payload I was giving in the custom token -- because I am using a cloud service function to generate the payload (graphcool), my payload was default: { appid } instead of just appid. From there, just some rewording on the permissions was enough to succeessdully validate using the current rules I'd imagined:
service cloud.firestore {
match /databases/{database}/documents {
match /sampleData/{type}/{appName}/{record} {
allow read: if isSignedIn() && isValidApp(database) && ownsExisting() && appIdInExisting()
allow write: if isSignedIn() && isValidApp(database) && ownsPayload() && appIdInPayload()
}
// functions
function isSignedIn () {
return request.auth != null
}
function isValidApp (database) {
return exists(/databases/$(database)/documents/apps/$(request.auth.token.appid))
}
function ownsExisting () {
return resource.data.uid == request.auth.uid
}
function ownsPayload () {
return request.resource.data.uid == request.auth.uid
}
function appIdInExisting () {
return resource.data.app_id == request.auth.token.appid
}
function appIdInPayload () {
return request.resource.data.app_id == request.auth.token.appid
}
}
}
Someone could do better though.
is there any way to validate the appid without using an exists request (And without writing a if-else if chain) -- like with an array directly in the rules perhaps?
how can I ensure that the appid specified in the payload matches the one I envisioned when I issued the service credentials to the client third-party-app?

Dynamic auth.token based on resource.data

I'm trying to write a rule allowing an user to access only the documents he has access to:
match /websites/{website} {
function isSignedIn() {
return request.auth.uid != null;
}
function isAuthorized(rsc) {
return request.auth.token[rsc.data.role] == true
}
function isAdmin() {
return request.auth.token.admin == true
}
allow read, write: if isSignedIn() && (isAuthorized(resource) || isAdmin());
}
Seems like there is a issue with request.auth.token[rsc.data.role] but I can't figure out what's the problem. rsc.data.role is set as a string in the database, and my user as a token as a boolean.
Example: website.role: 'editor' and request.auth.token.editor: true.
Here's a screenshot of the document I'm trying to access:
Any idea?

Error: Missing or insufficient permissions

I got a firestore like this:
:stores
|
$Store
:orders
|
$Order
:items
I want to read orders from my database using a user having an workerUid same as the request.auth.uid but geht the Error: Missing or insufficient permissions.
The important part of my firebase rules:
service cloud.firestore {
match /databases/{database}/documents {
//Matches any document in the stores collection
match /stores/{store} {
function isStoreAdmin(uid) {
return get(/databases/stores/$(store)).data.adminUid == uid;
}
function isStoreWorker(uid) {
return get(/databases/stores/$(store)).data.workerUid == uid;
}
allow read: if request.auth.uid != null;
allow write: if request.auth.uid == resource.data.adminUid;
//Matches any document in the orders collection
match /orders/{document=**} {
allow read, write: if isStoreAdmin(request.auth.uid) || isStoreWorker(request.auth.uid);
}
}
}
}
Funny thing is, that it works if I do this:
match /orders/{document=**} {
allow read, write: if isStoreWorker(request.auth.uid);
}
or this:
match /orders/{document=**} {
allow read, write: if request.aut.uid != null;
}
When deploying the rules I get no syntax error so I really can't understand why this is not working. Does anyone have any ideas? Thank you so much!
Edit:
function readAllDocuments(collectionReference, callback,finishedCallback){
collectionReference.get().then(function(querySnapshot) {
querySnapshot.forEach(function(doc) {
callback(doc.id,doc.data());
});
finishedCallback();
});
}
const storeDocument = getRootCollection(STORES_COLLECTION_ID).doc(storeId);
const orderCollection = storeDocument.collection(STOREORDERS_COLLECTION_ID);
orders=new Map();
readAllDocuments(orderCollection, function (id, data) {
orders.set(id,data);
},function(){
finishedLoading();
});
The documentation for use of get() in a security rule states:
...the path provided must begin with /databases/$(database)/documents
Make these changes to the get() paths:
function isStoreAdmin(uid) {
return get(/databases/$(database)/documents/stores/$(store)).data.adminUid == uid;
}
function isStoreWorker(uid) {
return get(/databases/$(database)/documents/stores/$(store)).data.workerUid == uid;
}

Resources