diff --git a/firestore.rules b/firestore.rules index 3573ec8..3dc6e8c 100644 --- a/firestore.rules +++ b/firestore.rules @@ -1,189 +1,229 @@ rules_version = '2'; service cloud.firestore { - match /databases/{database}/documents { - - match /users/{userID} { - allow read, delete: if isSignedIn() && (isSignedInUser(userID) || getUserData(request.auth.uid).admin); // is current user's data or is admin - allow update: if isSignedIn() && getUserData(request.auth.uid).admin && verifyFields([],["admin"]) && verifyUsersFieldTypes(); - // NOTE: create disallowed to ensure users can't define themselves as admins. Handled in Cloud Functions instead. - - match /private/settings { - allow read: if isSignedIn() && isSignedInUser(userID); // is current user's data - allow delete: if isSignedIn() && (isSignedInUser(userID) || getUserData(request.auth.uid).admin); - allow update: if isSignedIn() && isSignedInUser(userID) && verifyFields([],["display_name", "sound", "theme"]) && verifyUsersPrivateSettingsFieldTypes(); - allow create: if isSignedIn() && isSignedInUser(userID) && verifyFields(["display_name", "sound", "theme"],[]) && verifyUsersPrivateSettingsFieldTypes(); - } - match /groups/{groupID} { - allow read, delete: if isSignedIn() && (isSignedInUser(userID) || getUserGroupData(request.auth.uid, groupID).role == "owner" || getUserData(request.auth.uid).admin); // is current user's data or is owner of group or is admin - allow create: if isSignedIn() && (isSignedInUser(userID) || getUserGroupData(request.auth.uid, groupID).role == "owner" || getUserData(request.auth.uid).admin) && verifyFields(["role"],[]) && verifyUsersGroupsFieldTypes(); - allow update: if isSignedIn() && (isSignedInUser(userID) || getUserGroupData(request.auth.uid, groupID).role == "owner" || getUserData(request.auth.uid).admin) && verifyFields([],["role"]) && verifyUsersGroupsFieldTypes(); - } - } - - match /groups/{groupID} { - match /sets/{setID} { - allow delete: if isSignedIn() && (getUserGroupData(groupID).role == "owner" || getUserData(request.auth.uid).admin); - allow read: if isSignedIn() && (getUserGroupData(groupID).role || getUserData(request.auth.uid).admin); - allow create: if isSignedIn() && isSetIDValid(setID) && (getUserGroupData(groupID).role == "contributor" || getUserGroupData(groupID).role == "owner" || getUserData(request.auth.uid).admin) && verifyFields(["sets"],[]) && verifyGroupsFieldTypes(); - } - - match /static/data { - allow read, delete: if isSignedIn() && (getUserGroupData(groupID).role == "owner" || getUserData(request.auth.uid).admin); - allow create: if isSignedIn() && (getUserGroupData(groupID).role == "owner" || getUserData(request.auth.uid).admin) && verifyFields(["join_code"],[]) && verifyGroupsStaticDataFieldTypes(); - } - - match /restricted/data { - allow delete: if isSignedIn() && (getUserGroupData(groupID).role == "owner" || getUserData(request.auth.uid).admin); - allow read: if isSignedIn() && (getUserGroupData(groupID).role || getUserData(request.auth.uid).admin); - allow create: if isSignedIn() && (getUserGroupData(groupID).role == "owner" || getUserData(request.auth.uid).admin) && verifyFields(["display_name"],[]) && verifyGroupsFieldTypes(); - allow update: if isSignedIn() && (getUserGroupData(groupID).role == "owner" || getUserData(request.auth.uid).admin) && verifyFields([],["display_name"]) && verifyGroupsFieldTypes(); - } - } - - - match /sets/{setID} { - allow read, delete: if isSignedIn() && request.auth.uid == getSetOwner(setID); - allow read: if isSignedIn() && resource.data.public; - allow create: if isSignedIn() && request.auth.uid == getSetOwner(setID) && verifyFields(["public", "title"],[]) && verifySetsFieldTypes(); - allow update: if isSignedIn() && request.auth.uid == getSetOwner(setID) && verifyFields([],["public", "title"]) && verifySetsFieldTypes(); - - match /static/data { - allow read, delete: if isSignedIn() && request.auth.uid == resource.data.owner; - allow read: if isSignedIn() && getSetData(setID).public; - allow create: if isSignedIn() && request.auth.uid == request.resource.data.owner && verifyFields(["owner"],[]) && verifySetsStaticDataFieldTypes(); - } - - match /vocab/{vocabID} { - allow read, delete: if isSignedIn() && request.auth.uid == getSetOwner(setID); - allow read: if isSignedIn() && (getSetData(setID).public || request.auth.uid == getSetOwner(setID)); - allow create: if isSignedIn() && request.auth.uid == getSetOwner(setID) && verifyFields(["term", "sound", "definition"],[]) && verifySetsVocabFieldTypes(); - allow update: if isSignedIn() && request.auth.uid == getSetOwner(setID) && verifyFields([],["term", "sound", "definition"]) && verifySetsVocabFieldTypes(); - } - } - - match /progress/{progressID} { - allow read: if isSignedIn() && checkProgressUser(progressID); - allow delete: if isSignedIn() && checkProgressUser(progressID) && checkNotComplete(progressID); - // NOTE: update and create disallowed as these are handled by Cloud Functions to ensure sound file IDs aren't altered to illegally access other files - - match /static/data { - allow read: if isSignedIn() && checkProgressUser(progressID); - // NOTE: create handled by Cloud Functions - allow delete: if isSignedIn() && checkProgressUser(progressID) && checkNotComplete(progressID); - } - - match /terms/{vocabID} { - allow read: if isSignedIn() && checkProgressUser(progressID) && !(isLanguageSwitched(progressID)); - // NOTE: create handled by Cloud Functions - allow delete: if isSignedIn() && checkProgressUser(progressID) && checkNotComplete(progressID); - } - - match /definitions/{vocabID} { - allow read: if isSignedIn() && checkProgressUser(progressID) && isLanguageSwitched(progressID); - // NOTE: create handled by Cloud Functions - allow delete: if isSignedIn() && checkProgressUser(progressID) && checkNotComplete(progressID); - } - } - + match /databases/{database}/documents { function isSignedIn() { return request.auth != null; } - function isSignedInUser(userID) { - return request.auth.uid == userID; - } - function getUserData(userID) { - return get(/databases/$(database)/documents/users/$(userID)).data; - } - function getUserGroupData(userID, groupID) { - return get(/databases/$(database)/documents/users/$(userID)/groups/$(groupID)).data; - } - function checkProgressUser(progressID) { - return get(/databases/$(database)/documents/progress/$(progressID)/static/data).data.uid == request.auth.uid; - } - function checkNotComplete(progressID) { - return get(/databases/$(database)/documents/progress/$(progressID)).data.progress < get(/databases/$(database)/documents/progress/$(progressID)).data.questions.length; - } - function isLanguageSwitched(progressID) { - return get(/databases/$(database)/documents/progress/$(progressID)/static/data).data; - } - function getSetData(setID) { - return get(/databases/$(database)/documents/sets/$(setID)).data; - } - function getSetOwner(setID) { - return get(/databases/$(database)/documents/sets/$(setID)/static/data).data.owner; - } - function verifyFields(required, optional) { - return request.resource.data.keys().hasAll(required) && request.resource.data.keys().hasOnly(required.concat(optional)); - } - function isSetIDValid(setID) { - return getSetData(setID).public || getSetOwner(setID) == request.auth.uid; + + function isAdmin() { + return request.auth.token.admin; } - function verifyUsersFieldTypes() { - let data = request.resource.data; - return data.get("admin",true) is bool; + function getGroupRole(groupId) { + return get(/databases/$(database)/documents/users/$(request.auth.uid)/groups/$(groupId)).data.role; } - function verifyUsersPrivateSettingsFieldTypes() { - let data = request.resource.data; - return data.get("display_name",true) is bool && - data.get("sound",true) is bool && - data.get("theme","") is string; + + function isSetOwner(setId) { + return get(/databases/$(database)/documents/sets/$(setId)).data.owner == request.auth.uid; } - function verifyUsersGroupsFieldTypes() { - let data = request.resource.data; - return data.get("role","") is string && - (data.get("role","") == "member" || - data.get("role","") == "contributor" || - data.get("role","") == "owner"); + + function isPublicSet(setId) { + return get(/databases/$(database)/documents/sets/$(setId)).data.public; } - function verifyGroupsFieldTypes() { - let data = request.resource.data; - return data.get("display_name","") is string && - data.get("sets",[]) is list; + + function verifyCreateFields(fields) { + return request.resource.data.keys().hasAll(fields[0]) && request.resource.data.keys().hasOnly(fields[1]); } - function verifyGroupsStaticDataFieldTypes() { - let data = request.resource.data; - return data.get("join_code","") is string; + + function verifyUpdateFields(fields) { + let affectedKeys = request.resource.data.diff(resource.data).affectedKeys(); + return affectedKeys.hasAll([]) && affectedKeys.hasOnly(fields[1]); } - function verifySetsFieldTypes() { - let data = request.resource.data; - return data.get("public",true) is bool && - data.get("title","") is string; + + function getRequestField(field, default_value) { + return request.resource.data.get(field, default_value); } - function verifySetsStaticDataFieldTypes() { - let data = request.resource.data; - return data.get("owner","") is string; + + function verifyBoolType(field) { + return getRequestField(field, true) is bool; } - function verifySetsVocabFieldTypes() { - let data = request.resource.data; - return data.get("term","") is string && - data.get("sound","") is string && - data.get("definition","") is string; + + function verifyStringType(field) { + return getRequestField(field, "") is string; } - function verifyProgressFieldTypes() { - let data = request.resource.data; - return data.get("correct",[]) is list && - data.get("incorrect",[]) is list && - data.get("questions",[]) is list && - data.get("mark",0) is number && - data.get("progress",0) is number; + + match /users/{userId} { + function isSignedInUser() { + return request.auth.uid == userId; + } + + function verifyFieldTypes() { + return verifyStringType("display_name") && + verifyBoolType("sound") && + verifyStringType("theme"); + } + + function getPossibleFields() { + let requiredFields = ["display_name", "sound", "theme"]; + let optionalFields = []; + let allFields = requiredFields.concat(optionalFields); + return [requiredFields, allFields]; + } + + allow read: if isSignedIn() && isSignedInUser(); // is current user's data + allow update: if isSignedIn() && isSignedInUser() && verifyUpdateFields(getPossibleFields()) && verifyFieldTypes(); + allow delete: if isSignedIn() && (isSignedInUser() || isAdmin()); + + match /groups/{groupId} { + function verifyGroupFieldTypes() { + return getRequestField("role", "") == "member" || + getRequestField("role", "") == "contributor" || + getRequestField("role", "") == "owner"; + } + + function getPossibleGroupFields() { + let requiredFields = ["role"]; + let optionalFields = []; + let allFields = requiredFields.concat(optionalFields); + return [requiredFields, allFields]; + } + + allow read, delete: if isSignedIn() && (isSignedInUser() || getGroupRole(groupId) == "owner" || isAdmin()); // is current user's data or is owner of group or is admin + allow create: if isSignedIn() && ((isSignedInUser() && getRequestField("role", "") == "member") || ((getGroupRole(groupId) == "owner" || isAdmin()) && verifyGroupFieldTypes())) && verifyCreateFields(getPossibleGroupFields()); + allow update: if isSignedIn() && ((isSignedInUser() && getRequestField("role", "") == "member") || ((getGroupRole(groupId) == "owner" || isAdmin()) && verifyGroupFieldTypes())) && verifyUpdateFields(getPossibleGroupFields()); + } } - function verifyProgressStaticDataFieldTypes() { - let data = request.resource.data; - return data.get("set_title","") is string && - data.get("start_time",timestamp.date(2021,1,1)) is timestamp && - data.get("switch_language",true) is bool && - data.get("uid","") is string; + + match /groups/{groupId} { + function verifyFieldTypes() { + return verifyStringType("display_name"); + } + + function getPossibleFields() { + let requiredFields = ["display_name"]; + let optionalFields = []; + let allFields = requiredFields.concat(optionalFields); + return [requiredFields, allFields]; + } + + allow read: if isSignedIn() && (getGroupRole(groupId) != null || isAdmin()); + allow create: if isSignedIn() && getGroupRole(groupId) == "owner" && verifyCreateFields(getPossibleFields()) && verifyFieldTypes(); + allow update: if isSignedIn() && (getGroupRole(groupId) == "owner" || isAdmin()) && verifyUpdateFields(getPossibleFields()) && verifyFieldTypes(); + allow delete: if isSignedIn() && (getGroupRole(groupId) == "owner" || isAdmin()); + + match /sets/{setId} { + function isSetVisibleToUser(setId) { + return (isPublicSet(setId) || isSetOwner(setId)); + } + + function verifySetFieldTypes() { + return getRequestField("exists", true); + } + + function getPossibleSetFields() { + let requiredFields = ["exists"]; + let optionalFields = []; + let allFields = requiredFields.concat(optionalFields); + return [requiredFields, allFields]; + } + + + allow read: if isSignedIn() && ((getGroupRole(groupId) != null) || isAdmin()); + allow create: if isSignedIn() && isSetVisibleToUser(setId) && (getGroupRole(groupId) == "contributor" || getGroupRole(groupId) == "owner" || isAdmin()) && verifyCreateFields(getPossibleSetFields()) && verifySetFieldTypes(); + allow delete: if isSignedIn() && (getGroupRole(groupId) == "owner" || isAdmin()); + } + + match /static/data { + function verifyStaticFieldTypes() { + return verifyStringType("join_code"); + } + + function getPossibleStaticFields() { + let requiredFields = ["join_code"]; + let optionalFields = []; + let allFields = requiredFields.concat(optionalFields); + return [requiredFields, allFields]; + } + + allow read, delete: if isSignedIn() && (getGroupRole(groupId) == "owner" || isAdmin()); + allow create: if isSignedIn() && (getGroupRole(groupId) == "owner" || isAdmin()) && verifyCreateFields(getPossibleStaticFields()) && verifyStaticFieldTypes(); + } } - function verifyProgressTermsFieldTypes() { - let data = request.resource.data; - return data.get("term","") is string && - data.get("sound","") is string; + + + match /sets/{setId} { + function verifyFieldTypes() { + return verifyBoolType("public") && + verifyStringType("title") && + verifyStringType("owner"); + } + + function getPossibleFields() { + let nonStaticFields = ["public", "title"]; + let staticFields = ["owner"]; + let requiredFields = staticFields.concat(nonStaticFields); + let optionalFields = []; + let allFields = requiredFields.concat(optionalFields); + return [requiredFields, allFields, nonStaticFields]; + } + + function getPossibleCreateFields() { + let fields = getPossibleFields(); + return [fields[0], fields[1]]; + } + + function getPossibleUpdateFields() { + let fields = getPossibleFields(); + return [[], fields[2]]; + } + + + allow read, delete: if isSignedIn() && request.auth.uid == resource.data.owner; + allow read: if isSignedIn() && resource.data.public; + allow create: if isSignedIn() && request.auth.uid == request.resource.data.owner && verifyCreateFields(getPossibleCreateFields()) && verifyFieldTypes(); + allow update: if isSignedIn() && request.auth.uid == resource.data.owner && verifyUpdateFields(getPossibleUpdateFields()) && verifyFieldTypes(); + + match /vocab/{vocabId} { + function verifyVocabFieldTypes() { + return verifyStringType("term") && + verifyStringType("sound") && + verifyStringType("definition"); + } + + function getPossibleVocabFields() { + let requiredFields = ["term", "sound", "definition"]; + let optionalFields = []; + let allFields = requiredFields.concat(optionalFields); + return [requiredFields, allFields]; + } + + allow read, delete: if isSignedIn() && isSetOwner(setId); + allow read: if isSignedIn() && isPublicSet(setId); + allow create: if isSignedIn() && isSetOwner(setId) && verifyCreateFields(getPossibleVocabFields()) && verifyVocabFieldTypes(); + allow update: if isSignedIn() && isSetOwner(setId) && verifyUpdateFields(getPossibleVocabFields()) && verifyVocabFieldTypes(); + } } - function verifyProgressDefinitionsFieldTypes() { - let data = request.resource.data; - return data.get("definition","") is string; + + match /progress/{progressId} { + function isProgressUser() { + return get(/databases/$(database)/documents/progress/$(progressId)).data.uid == request.auth.uid; + } + + function isLanguageSwitched() { + return get(/databases/$(database)/documents/progress/$(progressId)).data.switch_language; + } + + function isNotComplete() { + return get(/databases/$(database)/documents/progress/$(progressId)).data.progress < get(/databases/$(database)/documents/progress/$(progressId)).data.questions.size(); + } + + allow read: if isSignedIn() && isProgressUser(); + allow delete: if isSignedIn() && isProgressUser() && isNotComplete(); + // TODO: update and create disallowed as these are handled by Cloud Functions to ensure sound file Ids aren't altered to illegally access other files + // TODO: disallow start_time update + + match /terms/{vocabId} { + allow read: if isSignedIn() && isProgressUser() && !(isLanguageSwitched()); + // TODO: create handled by Cloud Functions + allow delete: if isSignedIn() && isProgressUser() && isNotComplete(); + } + + match /definitions/{vocabId} { + allow read: if isSignedIn() && isProgressUser() && isLanguageSwitched(); + // TODO: create handled by Cloud Functions + allow delete: if isSignedIn() && isProgressUser() && isNotComplete(); + } } - } + } }