Repository navigation
Expand file tree
/
Copy pathfirestore.rules
More file actions
214 lines (181 loc) · 9.91 KB
/
Copy pathfirestore.rules
File metadata and controls
214 lines (181 loc) · 9.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
rules_version = '2';
// Split-It-Wise Firestore rules.
//
// Every permission check in src/lib/* runs in the browser, so it can be
// bypassed by calling the Firestore SDK directly from the console. These rules
// are the real enforcement; treat the client-side checks as UX only and keep
// the two in sync (see src/lib/groups.js and src/lib/expenses.js).
service cloud.firestore {
match /databases/{database}/documents {
function signedIn() {
return request.auth != null;
}
function groupData(groupId) {
return get(/databases/$(database)/documents/groups/$(groupId)).data;
}
function isGroupMember(groupId) {
return signedIn() && request.auth.uid in groupData(groupId).members;
}
function isGroupAdmin(groupId) {
return signedIn() && groupData(groupId).adminId == request.auth.uid;
}
// --- users --------------------------------------------------------
// Fetchable one document at a time by any signed-in user: getUsersByIds()
// resolves member names and avatars that way. Listing stays closed, because
// `read` would also permit getDocs(collection(db, "users")) — a dump of
// every user's name and photo to anyone with a Google account.
match /users/{userId} {
// Anything readable here is readable by every signed-in user, so a
// profile holds only what group members need to see of each other: a
// name and a photo. Older profiles may still carry an email or a UPI ID;
// both have to go before any update is accepted, which
// syncUserProfile() sees to on the owner's next sign-in.
function profileFields() {
return ['uid', 'name', 'photoURL', 'createdAt'];
}
allow get: if signedIn();
allow list: if false;
allow create: if signedIn()
&& request.auth.uid == userId
&& request.resource.data.uid == userId
&& request.resource.data.keys().hasOnly(profileFields());
// You may edit your own profile, but never your uid or your join date.
allow update: if signedIn()
&& request.auth.uid == userId
&& request.resource.data.uid == resource.data.uid
&& request.resource.data.createdAt == resource.data.createdAt
&& request.resource.data.keys().hasOnly(profileFields());
allow delete: if false;
}
// --- groups -------------------------------------------------------
match /groups/{groupId} {
// Every field a group document is allowed to carry. Without this an
// invitee could bring along arbitrary extra fields on the way in, since
// an update only has to leave the named fields alone.
function groupFields() {
return ['name', 'adminId', 'members', 'joinOpen', 'createdAt', 'updatedAt'];
}
// Groups created before the invite link could be switched off have no
// joinOpen field at all, and count as open.
function invitesOpen(data) {
return data.get('joinOpen', true) == true;
}
// The admin may rename the group, remove members, and open or close the
// invite link, but can never hand the group off or drop their own
// membership.
function adminEdit() {
return signedIn()
&& resource.data.adminId == request.auth.uid
&& request.resource.data.adminId == resource.data.adminId
&& request.resource.data.adminId in request.resource.data.members
&& request.resource.data.keys().hasOnly(groupFields())
&& request.resource.data.get('joinOpen', true) is bool
&& request.resource.data.createdAt == resource.data.createdAt
&& request.resource.data.updatedAt == request.time;
}
// An invitee adding themselves and changing nothing else. arrayUnion
// appends, so the new list must equal the old one with the uid on the end.
function selfJoin() {
return signedIn()
&& invitesOpen(resource.data)
&& !(request.auth.uid in resource.data.members)
&& request.resource.data.members == resource.data.members.concat([request.auth.uid])
&& request.resource.data.name == resource.data.name
&& request.resource.data.adminId == resource.data.adminId
&& request.resource.data.get('joinOpen', true) == resource.data.get('joinOpen', true)
&& request.resource.data.keys().hasOnly(groupFields())
&& request.resource.data.createdAt == resource.data.createdAt
&& request.resource.data.updatedAt == request.time;
}
// Any signed-in user may fetch a group by id: /join/[groupId] needs the
// group before the invitee is a member. That makes the 20-character
// Firestore auto-id the shared secret, which is what the invite links
// already assume.
allow get: if signedIn();
// Listing is limited to your own groups. getUserGroups() filters on
// members array-contains <uid>, which satisfies this.
allow list: if signedIn() && request.auth.uid in resource.data.members;
allow create: if signedIn()
&& request.resource.data.adminId == request.auth.uid
&& request.resource.data.members == [request.auth.uid]
&& request.resource.data.keys().hasOnly(groupFields())
&& request.resource.data.get('joinOpen', true) is bool;
allow update: if adminEdit() || selfJoin();
allow delete: if isGroupAdmin(groupId);
}
// --- expenses -----------------------------------------------------
match /expenses/{expenseId} {
// Every field an expense may carry, so neither a create nor an edit can
// smuggle in extras. percentages is only written for percentage splits.
function expenseFields() {
return ['groupId', 'amount', 'description', 'paidBy', 'createdBy', 'splitType',
'splits', 'percentages', 'createdAt', 'updatedAt'];
}
// What a stored expense must look like, on the way in and after an edit.
// Payer and split may only name current members, which is also what stops
// an edit from rewriting an expense that involves someone who has left.
function validExpense(data) {
return data.keys().hasOnly(expenseFields())
&& data.amount is number
&& data.amount > 0
&& data.amount < 10000000
&& data.paidBy in groupData(data.groupId).members
&& data.splitType in ['equal', 'exact', 'percentage']
&& data.splits is map
&& data.splits.keys().hasOnly(groupData(data.groupId).members)
&& data.get('percentages', {}) is map
&& data.description is string
&& data.description.size() <= 200;
}
allow read: if isGroupMember(resource.data.groupId);
allow create: if signedIn()
&& request.resource.data.createdBy == request.auth.uid
&& isGroupMember(request.resource.data.groupId)
&& validExpense(request.resource.data)
&& request.resource.data.createdAt == request.time;
// Same people as delete: whoever added it, or the admin. They must still
// be in the group, and the expense can't change hands — group, creator
// and creation time stay put, and updatedAt stamps the edit.
allow update: if signedIn()
&& isGroupMember(resource.data.groupId)
&& (resource.data.createdBy == request.auth.uid
|| isGroupAdmin(resource.data.groupId))
&& request.resource.data.groupId == resource.data.groupId
&& request.resource.data.createdBy == resource.data.createdBy
&& request.resource.data.createdAt == resource.data.createdAt
&& request.resource.data.updatedAt == request.time
&& validExpense(request.resource.data);
// The creator check comes first so that deleting your own expense costs
// no document reads. deleteGroup() batches deletes and relies on staying
// under the per-request access-call limit.
allow delete: if signedIn()
&& (resource.data.createdBy == request.auth.uid
|| isGroupAdmin(resource.data.groupId));
}
// --- settlements --------------------------------------------------
// Repayments between members. Any member may record one, including on
// someone else's behalf, because whoever is holding the phone when the
// cash changes hands is the one who will log it.
match /settlements/{settlementId} {
allow read: if isGroupMember(resource.data.groupId);
allow create: if signedIn()
&& request.resource.data.createdBy == request.auth.uid
&& isGroupMember(request.resource.data.groupId)
&& request.resource.data.amount is number
&& request.resource.data.amount > 0
&& request.resource.data.from != request.resource.data.to
&& request.resource.data.from in groupData(request.resource.data.groupId).members
&& request.resource.data.to in groupData(request.resource.data.groupId).members;
// Recorded a payment wrongly? Delete it and record it again.
allow update: if false;
// Either side of the payment can delete it too. Only the creator or the
// admin being able to would let a member record a repayment they never
// made, wiping their own debt, with the payee unable to take it back.
allow delete: if signedIn()
&& (resource.data.createdBy == request.auth.uid
|| resource.data.to == request.auth.uid
|| resource.data.from == request.auth.uid
|| isGroupAdmin(resource.data.groupId));
}
}
}