-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfirestore.rules
More file actions
307 lines (283 loc) · 15.8 KB
/
Copy pathfirestore.rules
File metadata and controls
307 lines (283 loc) · 15.8 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
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// The single platform admin. Must match PF_ADMIN_EMAIL in
// assets/js/firebase-config.js. Reads of leads, consultations and
// user records are granted ONLY to this signed-in account, so the
// in-app admin panel can show them while ordinary visitors cannot.
function isAdmin() {
return request.auth != null
&& request.auth.token.email == '[email protected]';
}
// An approved mentor account. Mirrors PFCloud.isMentor() in the client.
// Used to gate reads of the shared request queue and claim/update writes.
function isApprovedMentor() {
return request.auth != null
&& exists(/databases/$(database)/documents/mentors/$(request.auth.uid))
&& get(/databases/$(database)/documents/mentors/$(request.auth.uid)).data.approved == true;
}
// Root profile doc — just displayName/email for a NAMED account,
// written once per browser session by firebase.js so the admin's User
// records tab can identify a person instead of listing a bare uid.
// Never written for an anonymous session. Owner writes their own;
// admin may read (for the panel) and delete (for the "Delete all user
// records" reset tool).
match /users/{uid} {
allow read: if (request.auth != null && request.auth.uid == uid) || isAdmin();
allow create, update: if request.auth != null && request.auth.uid == uid;
allow delete: if isAdmin();
}
// Each user reads/writes only their own synced data; the admin may
// read everyone's, for the Users tab in the admin panel, and delete
// (not update) everyone's, for that same tab's "Delete all user
// records" reset tool. The {uid} wildcard also satisfies the
// collectionGroup('kv') query the admin panel runs, since isAdmin()
// does not depend on uid.
match /users/{uid}/kv/{key} {
allow read: if (request.auth != null && request.auth.uid == uid) || isAdmin();
allow create, update: if request.auth != null && request.auth.uid == uid;
allow delete: if (request.auth != null && request.auth.uid == uid) || isAdmin();
}
// Collection-group support for the admin "User records" tab.
// A collectionGroup('kv') query is NOT covered by the path-specific
// rule above, so admin reads across every users/*/kv subcollection
// need this recursive-wildcard rule. Read-only, admin-only.
match /{path=**}/kv/{key} {
allow read: if isAdmin();
}
// Email-capture inbox: anyone authenticated (incl. anonymous) may
// create; only the admin may read. Update/delete stay closed.
match /inbox_leads/{id} {
allow create: if request.auth != null
&& request.resource.data.email is string
&& request.resource.data.email.size() > 4
&& request.resource.data.email.size() < 200;
allow read: if isAdmin();
allow update, delete: if false;
}
// ── Mentor profiles ────────────────────────────────────────────────
// A user creates their own profile on sign-up with approved:false;
// only the admin may flip approved/active. Mentors may edit their own
// descriptive fields. `read` is open to any signed-in user because the
// isApprovedMentor() helper above does a get() on this doc.
match /mentors/{uid} {
allow create: if request.auth != null
&& request.auth.uid == uid
&& request.resource.data.approved == false
&& request.resource.data.displayName is string
&& request.resource.data.displayName.size() < 120;
allow read: if request.auth != null;
allow update: if isAdmin()
|| (request.auth.uid == uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['displayName','fields','city','bio','langs','availability','active']));
allow delete: if isAdmin();
}
// ── Mentor requests (the shared claim queue) ───────────────────────
// Replaces inbox_consultations. Anyone authenticated (incl. anonymous)
// may create an `open`, unclaimed request. Reads are limited to the
// admin, any approved mentor (the queue), or the owning student.
//
// The second and third create branches are phone/walk-in intake: when
// someone rings the platform with no account at all, the admin (or the
// mentor who answered) writes them down and the request is born already
// assigned, so it never sits in the open queue waiting for a claim. A
// mentor may only assign such a request to THEMSELVES; only the admin
// can hand one to another mentor.
match /mentor_requests/{id} {
allow create: if request.auth != null
&& (
(request.resource.data.status == 'open'
&& request.resource.data.mentorId == null)
|| (isAdmin() && request.resource.data.status == 'claimed')
// A mentor-authored request may only name a real studentUid when
// mentor_students already has a verified link for that pair —
// otherwise a mentor could mint a brand-new request claiming any
// account, then use it (via namedStudentIsOwn() below) to plant a
// session on a stranger's billing history. Ordinary phone/walk-in
// intake is unaffected: it always writes studentUid == ''.
|| (isApprovedMentor()
&& request.resource.data.status == 'claimed'
&& request.resource.data.mentorId == request.auth.uid
&& (request.resource.data.studentUid == ''
|| exists(/databases/$(database)/documents/mentor_students/$(request.auth.uid + '_' + request.resource.data.studentUid))))
)
&& request.resource.data.name is string
&& request.resource.data.name.size() < 200
&& request.resource.data.contact is string
&& request.resource.data.contact.size() < 200
&& request.resource.data.note is string
&& request.resource.data.note.size() < 2000;
allow read: if isAdmin()
|| isApprovedMentor()
|| (request.auth != null && resource.data.studentUid == request.auth.uid);
// Claim (open → claimed, taking mentorId atomically) OR a mentor
// updating a request they already own (status transitions, intro,
// payment fields, redeeming a plan credit against it). Admin may
// update anything. The first-claim race is closed by requiring the
// prior state to still be `open`/unclaimed.
//
// studentUid is pinned unchanged in both mentor branches — mirrors
// the same pin on mentor_sessions.update below. Without it a mentor
// could rewrite whose account a request (and, via namedStudentIsOwn(),
// a session logged against it) is attached to, planting a record in a
// stranger's billing list or a stranger's plan balance.
allow update: if isAdmin()
|| (isApprovedMentor() && (
(resource.data.status == 'open'
&& resource.data.mentorId == null
&& request.resource.data.status == 'claimed'
&& request.resource.data.mentorId == request.auth.uid)
|| (resource.data.mentorId == request.auth.uid
&& request.resource.data.mentorId == request.auth.uid)
)
&& request.resource.data.studentUid == resource.data.studentUid)
// A student may cancel their own request before it is paid.
|| (request.auth != null
&& resource.data.studentUid == request.auth.uid
&& request.resource.data.status == 'cancelled'
&& resource.data.status != 'paid'
&& resource.data.status != 'completed')
// A student may report their own payment (manual rail): only the
// payment block changes to 'reported'; status/mentor stay put, and
// the mentor/admin still confirms 'paid' afterwards.
|| (request.auth != null
&& resource.data.studentUid == request.auth.uid
&& resource.data.status == 'awaiting_payment'
&& request.resource.data.status == 'awaiting_payment'
&& request.resource.data.mentorId == resource.data.mentorId
&& request.resource.data.payment.paymentStatus == 'reported');
allow delete: if false;
}
// ── Mentor↔student links ────────────────────────────────────────────
// A tiny, create-only marker: "this mentor has a real, evidenced
// relationship with this student." It exists purely so a handful of
// other rules can check "does mentor X actually work with student Y"
// via exists() on a deterministic path — Firestore rules cannot run a
// query, only look up an exact document, so this is the mechanism that
// makes a scoped grant (reading one student's orders; naming a
// studentUid on a fresh request) possible without opening either up to
// every mentor. Doc id is `${mentorId}_${uid}`.
//
// A mentor may create their own marker only by pointing at a
// mentor_requests doc that already proves the pair (same technique as
// namedStudentIsOwn() above) — so this can only ever confirm a link
// that another rule already verified once, never manufacture a new one.
// The admin may create/remove any marker directly (e.g. after using the
// People tab's "Link to account" on an off-platform contact's history).
match /mentor_students/{key} {
allow read: if request.auth != null;
allow create: if isAdmin()
|| (isApprovedMentor()
&& key == request.auth.uid + '_' + request.resource.data.uid
&& request.resource.data.uid is string
&& request.resource.data.evidenceRequestId is string
&& get(/databases/$(database)/documents/mentor_requests/$(request.resource.data.evidenceRequestId)).data.mentorId == request.auth.uid
&& get(/databases/$(database)/documents/mentor_requests/$(request.resource.data.evidenceRequestId)).data.studentUid == request.resource.data.uid);
allow update: if false;
allow delete: if isAdmin();
}
// ── Mentoring session records ──────────────────────────────────────
// The delivered-work log behind every invoice. A mentor writes one per
// session — including the many that arrive by WhatsApp or a phone call
// and never pass through mentor_requests — so notes, duration, fee and
// payment state live in one auditable place.
//
// Only an approved mentor may create a record, and only for themselves
// (mentorId == their uid); the admin may log one on anyone's behalf.
// Reads: the owning mentor, the named student (so their invoice shows
// up in #billing), and the admin. A cancelled session is normally
// marked, not erased, so the ledger stays whole — the one exception is
// the admin's own "Delete all session records" reset tool (User
// records tab), a deliberate, explicitly-confirmed bulk wipe for
// clearing test data, not a per-record delete anyone else can reach.
// A session may only NAME a student account (studentUid) that the mentor
// already works with — one carried over from a request they own. For an
// off-platform session there is no such link, so studentUid stays empty
// and the invoice is addressed by name/contact alone. Without this a
// mentor could plant a record in a stranger's billing list.
function sessionRequest() {
return get(/databases/$(database)/documents/mentor_requests/$(request.resource.data.requestId)).data;
}
function namedStudentIsOwn() {
return request.resource.data.studentUid == ''
|| (request.resource.data.requestId != ''
&& sessionRequest().mentorId == request.auth.uid
&& sessionRequest().studentUid == request.resource.data.studentUid);
}
match /mentor_sessions/{id} {
allow create: if (isAdmin() || (isApprovedMentor()
&& request.resource.data.mentorId == request.auth.uid
&& namedStudentIsOwn()))
&& request.resource.data.studentName is string
&& request.resource.data.studentName.size() > 0
&& request.resource.data.studentName.size() < 200
&& request.resource.data.notes is string
&& request.resource.data.notes.size() < 5000
&& request.resource.data.summary is string
&& request.resource.data.summary.size() < 5000
&& request.resource.data.amountLKR is number
&& request.resource.data.amountLKR >= 0;
allow read: if isAdmin()
|| (request.auth != null && resource.data.mentorId == request.auth.uid)
|| (request.auth != null && resource.data.studentUid == request.auth.uid);
// The owning mentor may edit their own record (notes, fee, payment
// state) but can never reassign it to another mentor; admin may fix
// anything. Students have no write access — the record is the
// mentor's account of the work.
allow update: if isAdmin()
|| (isApprovedMentor()
&& resource.data.mentorId == request.auth.uid
&& request.resource.data.mentorId == resource.data.mentorId
&& request.resource.data.studentUid == resource.data.studentUid);
allow delete: if isAdmin();
}
// ── One-time premium unlocks (orders) ──────────────────────────────
// A signed-in user (incl. anonymous) creates their own order as
// 'reported' (manual rail) or 'pending' (future PayHere). Only the admin
// may mark it 'paid' after verifying the transfer; the owner may cancel
// before it is paid. Reads are limited to the owner and the admin.
//
// The admin may ALSO create an order directly on someone else's uid —
// the "grant a plan free" path (admin panel → User records). That
// branch is pinned to status:'paid' and amountLKR:0 so it can only ever
// comp a plan, never forge a real sale on another account.
match /orders/{id} {
allow create: if (
(request.auth != null
&& request.resource.data.uid == request.auth.uid
&& (request.resource.data.status == 'reported'
|| request.resource.data.status == 'pending'))
|| (isAdmin()
&& request.resource.data.status == 'paid'
&& request.resource.data.amountLKR == 0)
)
&& request.resource.data.item is string
&& request.resource.data.item.size() < 60
&& request.resource.data.amountLKR is number;
// The owner and the admin always could. A mentor may additionally see
// one student's orders — never the collection at large — once
// mentor_students records a verified link, so they can tell in the
// moment whether an off-platform session is covered by a paid plan.
allow read: if isAdmin()
|| (request.auth != null && resource.data.uid == request.auth.uid)
|| (isApprovedMentor()
&& exists(/databases/$(database)/documents/mentor_students/$(request.auth.uid + '_' + resource.data.uid)));
allow update: if isAdmin()
|| (request.auth != null
&& resource.data.uid == request.auth.uid
&& request.resource.data.status == 'cancelled'
&& resource.data.status != 'paid');
allow delete: if false;
}
// Legacy mentor consultation inbox (pre-marketplace). Kept read-only for
// the admin so historical requests remain visible; no new writes.
match /inbox_consultations/{id} {
allow read: if isAdmin();
allow create, update, delete: if false;
}
match /{document=**} {
allow read, write: if false;
}
}
}