security: restrict couple-level Firestore writes — immutable fields, owner-only messages/reactions, server-only deletes, valid state transitions
This commit is contained in:
parent
bd1ea5cecd
commit
c28ce9c58d
106
firestore.rules
106
firestore.rules
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue