| rules_version = '2'; |
|
|
| service cloud.firestore { |
| match /databases/{database}/documents { |
| function signedIn() { |
| return request.auth != null; |
| } |
|
|
| function isOwner(userId) { |
| return signedIn() && request.auth.uid == userId; |
| } |
|
|
| function isString(value, min, max) { |
| return value is string && value.size() >= min && value.size() <= max; |
| } |
|
|
| function validCrop(crop) { |
| return crop in ['corn', 'rice', 'soybean', 'wheat', 'tomato']; |
| } |
|
|
| function validCropList(crops) { |
| return crops is list && |
| crops.size() >= 1 && |
| crops.size() <= 5 && |
| validCrop(crops[0]) && |
| (crops.size() < 2 || validCrop(crops[1])) && |
| (crops.size() < 3 || validCrop(crops[2])) && |
| (crops.size() < 4 || validCrop(crops[3])) && |
| (crops.size() < 5 || validCrop(crops[4])); |
| } |
|
|
| function validStateCode(stateCode) { |
| return stateCode in [ |
| 'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA', |
| 'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD', |
| 'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ', |
| 'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC', |
| 'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY' |
| ]; |
| } |
|
|
| function validLatLng(lat, lng) { |
| return (lat == null || (lat is number && lat >= -90 && lat <= 90)) && |
| (lng == null || (lng is number && lng >= -180 && lng <= 180)); |
| } |
|
|
| function validSeverity(severity) { |
| return severity in ['low', 'medium', 'high']; |
| } |
|
|
| function validCropTroubleLocation(location) { |
| return location is map && |
| location.keys().hasOnly(['lat', 'lng', 'stateCode', 'generalArea', 'precision']) && |
| validLatLng(location.lat, location.lng) && |
| validStateCode(location.stateCode) && |
| isString(location.generalArea, 2, 120) && |
| location.precision in ['approximate', 'county_state', 'state']; |
| } |
|
|
| function validCropTroubleCreate() { |
| return request.resource.data.keys().hasOnly([ |
| 'userId', 'farmId', 'crop', 'issueType', 'disease', 'severity', 'description', |
| 'location', 'photoUrl', 'photoPath', 'photoShared', 'createdAt', 'status', |
| 'seeingTooCount', 'moderationCount' |
| ]) && |
| request.resource.data.userId == request.auth.uid && |
| (request.resource.data.farmId == null || request.resource.data.farmId is string) && |
| (request.resource.data.farmId == null || isFarmMember(request.resource.data.farmId)) && |
| validCrop(request.resource.data.crop) && |
| isString(request.resource.data.issueType, 1, 120) && |
| request.resource.data.disease == request.resource.data.issueType && |
| validSeverity(request.resource.data.severity) && |
| request.resource.data.description is string && |
| request.resource.data.description.size() <= 700 && |
| validCropTroubleLocation(request.resource.data.location) && |
| ( |
| ( |
| request.resource.data.photoShared == false && |
| request.resource.data.photoUrl == null && |
| request.resource.data.photoPath == null |
| ) || |
| ( |
| request.resource.data.photoShared == true && |
| isString(request.resource.data.photoUrl, 1, 2048) && |
| request.resource.data.photoPath == 'cropTroubleReports/' + request.resource.id + '/shared-photo' |
| ) |
| ) && |
| request.resource.data.createdAt == request.time && |
| request.resource.data.status == 'new' && |
| request.resource.data.seeingTooCount == 0 && |
| request.resource.data.moderationCount == 0 && |
| getAfter(/databases/$(database)/documents/reportRateLimits/$(request.auth.uid)).data.lastReportAt == request.time && |
| ( |
| !exists(/databases/$(database)/documents/reportRateLimits/$(request.auth.uid)) || |
| get(/databases/$(database)/documents/reportRateLimits/$(request.auth.uid)).data.lastReportAt < |
| request.time - duration.value(10, 'm') |
| ); |
| } |
|
|
| function validFarmCreate() { |
| return request.resource.data.keys().hasOnly([ |
| 'name', 'ownerId', 'joinCode', 'address', 'lat', 'lng', 'stateCode', |
| 'crops', 'acreage', 'verificationStatus', 'createdAt' |
| ]) && |
| request.resource.data.ownerId == request.auth.uid && |
| isString(request.resource.data.name, 1, 120) && |
| isString(request.resource.data.address, 1, 240) && |
| request.resource.data.joinCode.matches('^[A-Z2-9]{6}$') && |
| validStateCode(request.resource.data.stateCode) && |
| validCropList(request.resource.data.crops) && |
| validLatLng(request.resource.data.lat, request.resource.data.lng) && |
| (request.resource.data.acreage == null || |
| (request.resource.data.acreage is number && |
| request.resource.data.acreage >= 0 && |
| request.resource.data.acreage <= 1000000)) && |
| request.resource.data.verificationStatus == 'unverified' && |
| request.resource.data.createdAt == request.time; |
| } |
|
|
| function memberId(farmId, userId) { |
| return farmId + '_' + userId; |
| } |
|
|
| function requestId(farmId, userId) { |
| return farmId + '_' + userId; |
| } |
|
|
| function isFarmMember(farmId) { |
| return signedIn() && |
| exists(/databases/$(database)/documents/farmMembers/$(memberId(farmId, request.auth.uid))); |
| } |
|
|
| function isFarmOwner(farmId) { |
| return signedIn() && |
| get(/databases/$(database)/documents/farms/$(farmId)).data.ownerId == request.auth.uid; |
| } |
|
|
| function joinCodeAllowsMembership(data) { |
| return data.joinCodeUsed is string && |
| exists(/databases/$(database)/documents/farmJoinCodes/$(data.joinCodeUsed)) && |
| get(/databases/$(database)/documents/farmJoinCodes/$(data.joinCodeUsed)).data.farmId == data.farmId; |
| } |
|
|
| function approvedRequestAllowsMembership(data) { |
| return data.approvedRequestId is string && |
| exists(/databases/$(database)/documents/farmAccessRequests/$(data.approvedRequestId)) && |
| getAfter(/databases/$(database)/documents/farmAccessRequests/$(data.approvedRequestId)).data.farmId == data.farmId && |
| getAfter(/databases/$(database)/documents/farmAccessRequests/$(data.approvedRequestId)).data.requesterId == data.userId && |
| getAfter(/databases/$(database)/documents/farmAccessRequests/$(data.approvedRequestId)).data.ownerId == request.auth.uid && |
| getAfter(/databases/$(database)/documents/farmAccessRequests/$(data.approvedRequestId)).data.status == 'approved'; |
| } |
|
|
| function validFarmSearchIndex() { |
| return request.resource.data.keys().hasOnly([ |
| 'farmId', 'ownerId', 'name', 'address', 'stateCode', 'crops', 'searchTokens', 'updatedAt' |
| ]) && |
| request.resource.data.farmId is string && |
| request.resource.data.ownerId == request.auth.uid && |
| isString(request.resource.data.name, 1, 120) && |
| isString(request.resource.data.address, 1, 240) && |
| validStateCode(request.resource.data.stateCode) && |
| validCropList(request.resource.data.crops) && |
| request.resource.data.searchTokens is list && |
| request.resource.data.searchTokens.size() >= 1 && |
| request.resource.data.searchTokens.size() <= 40 && |
| request.resource.data.updatedAt == request.time; |
| } |
|
|
| function validAccessRequestCreate(farmId, requesterId) { |
| return request.resource.data.keys().hasOnly([ |
| 'farmId', 'requesterId', 'ownerId', 'status', 'farmName', 'farmStateCode', |
| 'farmAddress', 'requestedAt', 'requestExpiresAt' |
| ]) && |
| farmId == request.resource.data.farmId && |
| requesterId == request.resource.data.requesterId && |
| requesterId == request.auth.uid && |
| request.resource.data.status == 'pending' && |
| request.resource.data.requestedAt == request.time && |
| request.resource.data.requestExpiresAt is timestamp && |
| request.resource.data.requestExpiresAt > request.time && |
| request.resource.data.requestExpiresAt <= request.time + duration.value(30, 'd') && |
| !exists(/databases/$(database)/documents/farmMembers/$(memberId(farmId, requesterId))) && |
| exists(/databases/$(database)/documents/farmSearchIndex/$(farmId)) && |
| get(/databases/$(database)/documents/farmSearchIndex/$(farmId)).data.ownerId == request.resource.data.ownerId && |
| get(/databases/$(database)/documents/farmSearchIndex/$(farmId)).data.name == request.resource.data.farmName && |
| get(/databases/$(database)/documents/farmSearchIndex/$(farmId)).data.stateCode == request.resource.data.farmStateCode && |
| get(/databases/$(database)/documents/farmSearchIndex/$(farmId)).data.address == request.resource.data.farmAddress && |
| request.resource.data.ownerId != request.auth.uid; |
| } |
|
|
| match /users/{userId} { |
| allow read: if isOwner(userId); |
| allow create: if isOwner(userId) && |
| request.resource.data.keys().hasOnly(['name', 'email', 'emailVerified', 'createdAt']) && |
| isString(request.resource.data.name, 1, 100) && |
| isString(request.resource.data.email, 3, 320) && |
| request.resource.data.emailVerified is bool && |
| request.resource.data.createdAt == request.time; |
| allow update: if isOwner(userId) && |
| request.resource.data.diff(resource.data).changedKeys().hasOnly(['name', 'email', 'emailVerified']) && |
| isString(request.resource.data.name, 1, 100) && |
| isString(request.resource.data.email, 3, 320) && |
| request.resource.data.emailVerified is bool; |
| allow delete: if false; |
| } |
|
|
| match /farms/{farmId} { |
| allow read: if isFarmMember(farmId); |
| allow create: if signedIn() && validFarmCreate(); |
| allow update: if isFarmOwner(farmId) && |
| request.resource.data.diff(resource.data).changedKeys().hasOnly(['joinCode', 'joinCodeUpdatedAt']) && |
| request.resource.data.joinCode.matches('^[A-Z2-9]{6}$') && |
| request.resource.data.joinCodeUpdatedAt == request.time && |
| existsAfter(/databases/$(database)/documents/farmJoinCodes/$(request.resource.data.joinCode)) && |
| getAfter(/databases/$(database)/documents/farmJoinCodes/$(request.resource.data.joinCode)).data.farmId == farmId && |
| getAfter(/databases/$(database)/documents/farmJoinCodes/$(request.resource.data.joinCode)).data.createdBy == request.auth.uid; |
| allow delete: if false; |
| } |
|
|
| match /farmJoinCodes/{joinCode} { |
| allow get: if signedIn() && joinCode.matches('^[A-Z2-9]{6}$'); |
| allow list: if false; |
| allow create: if signedIn() && |
| joinCode.matches('^[A-Z2-9]{6}$') && |
| request.resource.data.keys().hasOnly(['farmId', 'createdBy', 'createdAt']) && |
| request.resource.data.createdBy == request.auth.uid && |
| request.resource.data.createdAt == request.time && |
| getAfter(/databases/$(database)/documents/farms/$(request.resource.data.farmId)).data.ownerId == request.auth.uid; |
| allow update: if false; |
| allow delete: if signedIn() && |
| resource.data.farmId is string && |
| get(/databases/$(database)/documents/farms/$(resource.data.farmId)).data.ownerId == request.auth.uid; |
| } |
|
|
| match /farmSearchIndex/{farmId} { |
| allow read: if signedIn(); |
| allow create, update: if signedIn() && |
| farmId == request.resource.data.farmId && |
| validFarmSearchIndex() && |
| getAfter(/databases/$(database)/documents/farms/$(farmId)).data.ownerId == request.auth.uid; |
| allow delete: if false; |
| } |
|
|
| match /farmAccessRequests/{accessRequestId} { |
| allow read: if signedIn() && |
| (resource.data.requesterId == request.auth.uid || resource.data.ownerId == request.auth.uid); |
| allow create: if signedIn() && |
| accessRequestId == requestId(request.resource.data.farmId, request.resource.data.requesterId) && |
| validAccessRequestCreate(request.resource.data.farmId, request.resource.data.requesterId); |
| allow update: if signedIn() && |
| ( |
| ( |
| resource.data.ownerId == request.auth.uid && |
| resource.data.status == 'pending' && |
| request.resource.data.diff(resource.data).changedKeys().hasOnly(['status', 'resolvedBy', 'resolvedAt']) && |
| request.resource.data.status in ['approved', 'denied'] && |
| request.resource.data.resolvedBy == request.auth.uid && |
| request.resource.data.resolvedAt == request.time |
| ) || |
| ( |
| resource.data.requesterId == request.auth.uid && |
| resource.data.status == 'pending' && |
| request.time > resource.data.requestExpiresAt && |
| request.resource.data.diff(resource.data).changedKeys().hasOnly(['status']) && |
| request.resource.data.status == 'expired' |
| ) |
| ); |
| allow delete: if false; |
| } |
|
|
| match /farmMembers/{memberDocId} { |
| allow read: if signedIn() && resource.data.userId == request.auth.uid; |
| allow create: if signedIn() && |
| memberDocId == memberId(request.resource.data.farmId, request.resource.data.userId) && |
| request.resource.data.keys().hasOnly(['farmId', 'userId', 'role', 'joinedAt', 'joinCodeUsed', 'approvedRequestId']) && |
| request.resource.data.joinedAt == request.time && |
| ( |
| ( |
| request.resource.data.role == 'owner' && |
| request.resource.data.userId == request.auth.uid && |
| !('joinCodeUsed' in request.resource.data) && |
| !('approvedRequestId' in request.resource.data) && |
| getAfter(/databases/$(database)/documents/farms/$(request.resource.data.farmId)).data.ownerId == request.auth.uid |
| ) || |
| ( |
| request.resource.data.role == 'member' && |
| request.resource.data.userId == request.auth.uid && |
| !('approvedRequestId' in request.resource.data) && |
| joinCodeAllowsMembership(request.resource.data) |
| ) || |
| ( |
| request.resource.data.role == 'member' && |
| isFarmOwner(request.resource.data.farmId) && |
| !('joinCodeUsed' in request.resource.data) && |
| approvedRequestAllowsMembership(request.resource.data) |
| ) |
| ); |
| allow update: if signedIn() && |
| resource.data.userId == request.auth.uid && |
| request.resource.data.diff(resource.data).changedKeys().hasOnly(['joinedAt']); |
| allow delete: if signedIn() && |
| resource.data.userId == request.auth.uid && |
| resource.data.role != 'owner'; |
| } |
|
|
| match /diagnoses/{diagnosisId} { |
| allow read: if signedIn() && resource.data.userId == request.auth.uid; |
| allow create: if signedIn() && |
| request.resource.data.keys().hasOnly(['userId', 'farmId', 'crop', 'disease', 'confidence', 'detectedAt']) && |
| request.resource.data.userId == request.auth.uid && |
| isFarmMember(request.resource.data.farmId) && |
| validCrop(request.resource.data.crop) && |
| isString(request.resource.data.disease, 1, 200) && |
| request.resource.data.confidence is number && |
| request.resource.data.confidence >= 0 && |
| request.resource.data.confidence <= 100 && |
| request.resource.data.detectedAt == request.time; |
| allow update, delete: if false; |
| } |
|
|
| match /reportRateLimits/{userId} { |
| allow read: if isOwner(userId); |
| allow create, update: if isOwner(userId) && |
| request.resource.data.keys().hasOnly(['userId', 'lastReportAt']) && |
| request.resource.data.userId == request.auth.uid && |
| request.resource.data.lastReportAt == request.time; |
| allow delete: if false; |
| } |
|
|
| match /cropTroubleReports/{reportId} { |
| allow read: if signedIn(); |
| allow create: if signedIn() && validCropTroubleCreate(); |
| allow update: if signedIn() && ( |
| ( |
| resource.data.userId == request.auth.uid && |
| request.resource.data.diff(resource.data).changedKeys().hasOnly(['status']) && |
| request.resource.data.status in ['confirmed', 'resolved'] |
| ) || |
| ( |
| request.resource.data.diff(resource.data).changedKeys().hasOnly(['seeingTooCount']) && |
| request.resource.data.seeingTooCount == resource.data.seeingTooCount + 1 && |
| !exists(/databases/$(database)/documents/cropTroubleReports/$(reportId)/seeingToo/$(request.auth.uid)) && |
| getAfter(/databases/$(database)/documents/cropTroubleReports/$(reportId)/seeingToo/$(request.auth.uid)).data.userId == request.auth.uid |
| ) || |
| ( |
| request.resource.data.diff(resource.data).changedKeys().hasOnly(['moderationCount']) && |
| request.resource.data.moderationCount == resource.data.moderationCount + 1 && |
| !exists(/databases/$(database)/documents/cropTroubleReports/$(reportId)/moderationReports/$(request.auth.uid)) && |
| getAfter(/databases/$(database)/documents/cropTroubleReports/$(reportId)/moderationReports/$(request.auth.uid)).data.userId == request.auth.uid |
| ) |
| ); |
| allow delete: if signedIn() && resource.data.userId == request.auth.uid; |
|
|
| match /seeingToo/{userId} { |
| allow read: if signedIn(); |
| allow create: if isOwner(userId) && |
| request.resource.data.keys().hasOnly(['userId', 'createdAt']) && |
| request.resource.data.userId == request.auth.uid && |
| request.resource.data.createdAt == request.time; |
| allow update, delete: if false; |
| } |
|
|
| match /moderationReports/{userId} { |
| allow read: if signedIn() && userId == request.auth.uid; |
| allow create: if isOwner(userId) && |
| request.resource.data.keys().hasOnly(['userId', 'reason', 'createdAt']) && |
| request.resource.data.userId == request.auth.uid && |
| isString(request.resource.data.reason, 1, 80) && |
| request.resource.data.createdAt == request.time; |
| allow update, delete: if false; |
| } |
| } |
|
|
| match /{document=**} { |
| allow read, write: if false; |
| } |
| } |
| } |
|
|