From 19c6b4a6cb38bf008a05b15123dcf3854751e08e Mon Sep 17 00:00:00 2001 From: null Date: Wed, 17 Jun 2026 19:41:27 -0500 Subject: [PATCH] fix: real uid in bucket list, Firestore rules hardening for date plans & bucket list --- .../closer/ui/dates/BucketListViewModel.kt | 5 +- firestore.rules | 52 +++++++++++++++++-- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/app/closer/ui/dates/BucketListViewModel.kt b/app/src/main/java/app/closer/ui/dates/BucketListViewModel.kt index 71906749..b84801a1 100644 --- a/app/src/main/java/app/closer/ui/dates/BucketListViewModel.kt +++ b/app/src/main/java/app/closer/ui/dates/BucketListViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.closer.domain.model.BucketListItem import app.closer.domain.repository.BucketListRepository +import com.google.firebase.auth.FirebaseAuth import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -48,7 +49,7 @@ class BucketListViewModel @Inject constructor( title = title.take(MAX_TITLE_LENGTH), description = description.take(MAX_DESCRIPTION_LENGTH), category = category, - addedBy = "currentUser", + addedBy = FirebaseAuth.getInstance().currentUser?.uid ?: "", addedAt = System.currentTimeMillis() ) @@ -71,7 +72,7 @@ class BucketListViewModel @Inject constructor( ) } } else { - repository.completeItem(itemId, "currentUser") + repository.completeItem(itemId, FirebaseAuth.getInstance().currentUser?.uid ?: "") _uiState.update { it.copy( items = it.items.map { diff --git a/firestore.rules b/firestore.rules index 9193d637..0fe211fb 100644 --- a/firestore.rules +++ b/firestore.rules @@ -47,6 +47,16 @@ service cloud.firestore { return action == 'love' || action == 'maybe' || action == 'skip'; } + function isValidDatePlanStatus(status) { + return status == 'draft' || status == 'planned' || status == 'completed'; + } + + function isValidBucketListCategory(category) { + return category == 'adventure' || category == 'travel' || category == 'food' + || category == 'learning' || category == 'romance' || category == 'intimacy' + || category == 'seasonal'; + } + // ── Users ───────────────────────────────────────────────────────────────── // Each user owns exactly their own document. // hasPremium is server-only: clients may not write it directly. @@ -293,17 +303,51 @@ service cloud.firestore { } // Date plans: complete plans assembled from partner preferences. - // Both members can read; create/update/delete by both members. + // Both members can read and delete; writes are field-validated. + // createdAt is immutable after creation (excluded from the update allowed-keys set). match /date_plans/{planId} { allow read: if isCouplesMember(coupleId); - allow create, update, delete: if isCouplesMember(coupleId); + allow create: if isCouplesMember(coupleId) + && request.resource.data.keys().hasAll(['dateIdeaId', 'scheduledDate', 'status', 'createdAt', 'updatedAt']) + && request.resource.data.keys().hasOnly([ + 'dateIdeaId', 'scheduledDate', 'scheduledTime', 'budget', 'duration', + 'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge', + 'createdAt', 'updatedAt' + ]) + && isValidDatePlanStatus(request.resource.data.status); + allow update: if isCouplesMember(coupleId) + // Only the explicitly-listed fields may change on update. + // createdAt is intentionally absent — it cannot be modified after creation. + && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ + 'dateIdeaId', 'scheduledDate', 'scheduledTime', 'budget', 'duration', + 'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge', + 'updatedAt' + ]) + && isValidDatePlanStatus(request.resource.data.status); + allow delete: if isCouplesMember(coupleId); } // Bucket list items: shared list for both partners. - // Both members can read; create/update/delete by both members. + // addedBy must match the caller on creation; addedBy and addedAt are immutable. + // Marking an item complete requires the caller to own the completedBy field. match /bucket_list/{itemId} { allow read: if isCouplesMember(coupleId); - allow create, update, delete: if isCouplesMember(coupleId); + allow create: if isCouplesMember(coupleId) + && request.resource.data.keys().hasAll(['title', 'addedBy', 'addedAt', 'isCompleted']) + && request.resource.data.keys().hasOnly([ + 'title', 'description', 'category', 'addedBy', 'addedAt', + 'completedBy', 'completedAt', 'isCompleted' + ]) + && request.resource.data.addedBy == request.auth.uid + && isValidBucketListCategory(request.resource.data.category); + allow update: if isCouplesMember(coupleId) + && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ + 'title', 'description', 'category', 'isCompleted', 'completedBy', 'completedAt' + ]) + && isImmutable(['addedBy', 'addedAt']) + // completedBy must be the caller when marking an item complete + && (!request.resource.data.isCompleted || request.resource.data.completedBy == request.auth.uid); + allow delete: if isCouplesMember(coupleId); } } }