security: restrict couple-level Firestore writes — immutable fields, owner-only messages/reactions, server-only deletes, valid state transitions

This commit is contained in:
null 2026-06-16 21:46:56 -05:00
parent bd1ea5cecd
commit c28ce9c58d
1 changed files with 98 additions and 8 deletions

View File

@ -35,6 +35,21 @@ service cloud.firestore {
}
}
function isServer() {
// Check if request comes from admin SDK (no request.auth)
return request.auth == null;
}
function isImmutable(fields) {
// Helper to check that certain fields haven't changed during an update
// fields: list of field names that should be immutable
if (resource == null) {
// Create operation - nothing to check
return true;
}
return fields.every(f => resource.data[f] == request.resource.data[f]);
}
// ── Users ─────────────────────────────────────────────────────────────────
// Each user owns exactly their own document.
// hasPremium is server-only: clients may not write it directly.
@ -96,18 +111,81 @@ service cloud.firestore {
}
// ── Couples ───────────────────────────────────────────────────────────────
// Only the two members of a couple may read or write couple data.
// Only the two members of a couple may read couple data.
// Writes are restricted by field ownership and immutability.
match /couples/{coupleId} {
allow read, write: if isCouplesMember(coupleId);
// Read: both members can read
allow read: if isCouplesMember(coupleId);
// Create: only via invite flow (server-side or admin SDK)
allow create: if isServer();
// Update: field-level restrictions
// - user IDs are immutable (cannot change who is in the couple)
// - invite code is immutable (cannot change the code)
// - createdAt is immutable (cannot change when the couple was formed)
// - currentQuestionId: either member can set (either can pick a question)
// - streakCount and lastStreakAt: server-only (via Cloud Functions or admin SDK)
// - Any other fields: both members can update normally
allow update: if isCouplesMember(coupleId)
// Check immutable fields haven't changed
&& isImmutable(['userIds', 'inviteCode', 'createdAt'])
// Allow currentQuestionId updates
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['currentQuestionId'])
// No other fields should be changed
// Check that streakCount and lastStreakAt are not in the update
&& !request.resource.data.diff(resource.data).affectedKeys().hasAny(['streakCount', 'lastStreakAt']);
// Delete: server-only (admin SDK only)
allow delete: if isServer();
match /sessions/{sessionId} {
allow read, write: if isCouplesMember(coupleId);
// Read: both members can read
allow read: if isCouplesMember(coupleId);
// Create: either member can start a session
allow create: if isCouplesMember(coupleId)
&& request.resource.data.startedByUserId == request.auth.uid;
// Update: only the user who started the session can update it, OR valid status transitions
allow update: if isCouplesMember(coupleId)
// Either the original starter can update
&& (resource.data.startedByUserId == request.auth.uid
// Or status transition is valid: active → completed
|| (resource.data.status == 'active' && request.resource.data.status == 'completed'))
// Check that other fields haven't been tampered with
&& (request.resource.data.startedByUserId == resource.data.startedByUserId
|| isServer());
// Delete: server-only (admin SDK)
allow delete: if isServer();
}
// Question threads live under the couple document.
match /question_threads/{threadId} {
allow read, write: if isCouplesMember(coupleId);
// Read: both members can read
allow read: if isCouplesMember(coupleId);
// Create: either member can create a thread
allow create: if isCouplesMember(coupleId)
&& request.resource.data.createdByUserId == request.auth.uid;
// Update: valid state transitions only, currentIndex only incrementable
allow update: if isCouplesMember(coupleId)
// Status transitions must be valid: NOT_STARTED → ANSWERED_BY_ONE → REVEALED → COMPLETED
&& (resource.data.status == 'NOT_STARTED' && request.resource.data.status == 'ANSWERED_BY_ONE'
|| resource.data.status == 'ANSWERED_BY_ONE' && request.resource.data.status == 'REVEALED'
|| resource.data.status == 'REVEALED' && request.resource.data.status == 'COMPLETED'
|| resource.data.status == 'ANSWERED_BY_ONE' && request.resource.data.status == 'COMPLETED')
// currentIndex can only be incremented, never decremented or reset
&& request.resource.data.currentIndex != null
&& (resource.data.currentIndex == null || request.resource.data.currentIndex >= resource.data.currentIndex)
// No other fields should change
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'currentIndex']);
// Delete: server-only (admin SDK)
allow delete: if isServer();
// Answers: each user writes their own; both members can read all answers.
match /answers/{userId} {
@ -115,14 +193,26 @@ service cloud.firestore {
allow read: if isCouplesMember(coupleId);
}
// Discussion messages: any couple member can write and read.
// Discussion messages: any couple member can read, but only the author can write/update/delete
match /messages/{messageId} {
allow read, write: if isCouplesMember(coupleId);
allow read: if isCouplesMember(coupleId);
allow create: if isCouplesMember(coupleId)
&& request.resource.data.authorUserId == request.auth.uid;
allow update: if isCouplesMember(coupleId)
&& resource.data.authorUserId == request.auth.uid;
allow delete: if isCouplesMember(coupleId)
&& resource.data.authorUserId == request.auth.uid;
}
// Reactions: any couple member can write and read.
// Reactions: any couple member can read, but only the creator can write/update/delete
match /reactions/{reactionId} {
allow read, write: if isCouplesMember(coupleId);
allow read: if isCouplesMember(coupleId);
allow create: if isCouplesMember(coupleId)
&& request.resource.data.userId == request.auth.uid;
allow update: if isCouplesMember(coupleId)
&& resource.data.userId == request.auth.uid;
allow delete: if isCouplesMember(coupleId)
&& resource.data.userId == request.auth.uid;
}
}
}