-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathfirestore.rules
More file actions
1072 lines (965 loc) · 59.4 KB
/
Copy pathfirestore.rules
File metadata and controls
1072 lines (965 loc) · 59.4 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
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
rules_version = '2';
// Firestore security rules for scaleropensourcelabs.com
//
// THIS FILE IS THE SECURITY BOUNDARY. The Firebase web config in the client bundle is a
// public identifier, not a credential (see web/lib/firebase.ts), and the site is a
// static export with no server — so there is no back end doing checks. Everything that
// protects members' data is in this file.
//
// The site now has sign-in, a dashboard and an organisers' page. Ten collections:
//
// users/{uid} one profile per member. Readable and writable ONLY by that
// member, plus readable by admins. Contains their name, college
// address and hostel.
// admins/{email} membership = adminship. Nobody can write it from any client.
// mentors/{id} the mentor list an organiser publishes. Readable by every member,
// WRITABLE BY ADMINS — see below.
// enrollments/{uid} one per member: which mentors they want, in order. Theirs to
// write and to withdraw; admins may list them all.
// applications/{id} legacy, from before sign-in. Create-only and unreadable, kept
// so the rows already there stay protected.
//
// And the five the dashboard brought with it:
//
// announcements/{id} the notice board. Every member reads it; admins write it.
// sessions/{id} when the club meets. Same access as the board, different shape:
// a session has a time, and that time is the ONE date in this file
// the client is allowed to choose.
// forms/{id} forms and polls. Every member reads them; admins write them.
// forms/{id}/responses/{uid}
// one answer per member, keyed by uid so a second is impossible to
// express. Theirs to write, ADMINS ONLY to list — that list rule is
// the only thing between an attributed form and a public one.
// contributions/{uid}
// GitHub counts. Read by their owner and by admins, WRITTEN BY NO
// CLIENT AT ALL: the Cloud Function uses the Admin SDK, which
// bypasses this file. A count a client can write is a count a
// client can invent, and "merged pull requests" is the one number
// here somebody has a reason to inflate.
//
// TWO THINGS CHANGED IN THE BLOCKS ABOVE WHEN THOSE ARRIVED, and both are easy to miss:
//
// `admins` IS NO LONGER WRITE-DENIED TO EVERY CLIENT. It is the core-team roster now
// (web/lib/roster.ts) — the access grant and the public team listing in one row — and
// OWNERS write it from /admin. That is a real widening, and it is fenced three ways:
// only an owner may write, NOBODY may write their OWN row, and NOBODY may delete a row.
// So one compromised admin account still cannot appoint accomplices, and a compromised
// owner cannot retire everybody else and be the only one left holding the club —
// retiring is a flag any other owner can turn back on, and the row stays as the record.
//
// isAdmin() NOW READS THAT FLAG. It used to test only that the row existed, so retiring
// somebody took their buttons away and left every permission intact. web/lib/auth.tsx
// has always applied the same two defaults — a missing flag means active, a missing role
// means plain admin — and the two files have to keep agreeing.
//
// `mentors` IS THE FIRST COLLECTION A CLIENT MAY WRITE THAT IS NOT ITS OWN ROW, and that
// widening was deliberate rather than incidental. It is acceptable because a mentor entry
// is published, organiser-authored, non-personal copy — the same kind of thing that lives
// in web/content/ — so the worst a stolen admin session can do here is deface a list. It
// cannot read a member's details it could not already read, and it cannot grant itself
// anything. Note that the same argument does NOT hold for `admins`, which is why every
// client write to that collection stays denied: appointing an admin is the one privilege
// escalation this model would otherwise allow.
//
// WHAT IS NOT A FIELD HERE, ON PURPOSE. Batch, branch and year are not stored anywhere.
// They are read out of the address — `abhinav.23bcs10045@sst.scaler.com` — by
// web/lib/batch.ts, and the address is pinned to `request.auth.token.email` below. A
// derived value cannot disagree with the document it describes, so there is nothing here
// to validate and nothing that can drift.
//
// ONLY @sst.scaler.com MAY REGISTER, and this is where that is enforced. web/lib/auth.tsx
// also passes the domain to Google and signs out anyone off-domain, but both of those
// are conveniences: a client can be modified, and a token from another domain is still a
// valid token. `isStudent()` below is what actually refuses.
//
// email_verified is required as well as the domain. A Google Workspace sign-in always
// carries it, so this costs nothing today — but it means that if email/password sign-in
// is ever enabled, an unverified person who typed somebody else's college address still
// cannot read or write anything. Without it, adding a password form later would quietly
// open exactly the hole this file exists to close.
//
// DEPLOY WITH: firebase deploy --only firestore:rules
// Editing this file alone changes nothing. A rules file that is correct in git and
// permissive in production is the worst case, because review passes.
//
// THE CLOSED SETS BELOW MIRROR web/content/join.ts AND MUST NOT DRIFT. Adding an option
// there and not here means a real member gets a permission error on save, which presents
// as an outage rather than as a validation failure. Two of these values were wrong on the
// first attempt — `some` for `some-git`, `hackathon` for `build-day` — which is why
// `npm run rules` diffs this file against the content and fails the build.
service cloud.firestore {
match /databases/{database}/documents {
// ---------------------------------------------------------------- helpers
/** A STUDENT AT THIS COLLEGE. Signed in, on the club's domain, with a verified
* address. The domain test is a regex anchored at BOTH ends: `endsWith` alone
* would accept "eve@evil.com@sst.scaler.com" and a leading-wildcard match would
* accept "sst.scaler.com.evil.com".
*
* THIS FUNCTION WAS CALLED isMember() AND THE NAME WAS A BUG. It says nothing
* about the club — every student on the domain satisfies it the first time they
* sign in. But it was the gate on announcements, forms and sessions, so "members
* only" was never a state the club could express: everything the organisers
* posted went to the entire college. Membership is now a separate question, asked
* by isClubMember() below, and the two must never be confused again. */
function isStudent() {
return request.auth != null
&& request.auth.token.email != null
&& request.auth.token.email_verified == true
&& request.auth.token.email.lower().matches('^[^@]+@sst[.]scaler[.]com$');
}
/** The caller's OWN profile, or an empty map if they have never made one.
*
* Guarded with exists() rather than get()'d directly: a get() on a missing
* document is an ERROR, not an empty result, and it fails the whole rule. A
* student who signed in and never filled the profile form is the ordinary case,
* not an edge one, so this must return "no membership" for them rather than
* denying every read they attempt. */
function profileRow() {
return exists(/databases/$(database)/documents/users/$(request.auth.uid))
? get(/databases/$(database)/documents/users/$(request.auth.uid)).data
: {};
}
/** A MEMBER OF THE CLUB, which is a smaller set than isStudent() and is the whole
* point of the split. Read off the reader's own profile, where only an admin can
* have written it — see the users/{uid} block for the field-level rule that stops
* somebody promoting themselves.
*
* ABSENT MEANS NOT A MEMBER, which is the opposite of the default this file uses
* for `active` on an admin row, and deliberately so: an absent `active` had to
* mean "yes" because rows predating the field belonged to organisers who already
* had access and would otherwise have been locked out. An absent `membership`
* belongs to a student who has signed in, which has never by itself meant
* membership in anything. Defaulting it to "member" would hand the club's private
* board to the entire domain — which is the exact bug this change exists to fix. */
function isClubMember() {
return isStudent() && profileRow().get('membership', 'student') == 'member';
}
/** An admin is a member whose address has a document in `admins`. Looked up by
* email rather than uid so organisers can be added before they first sign in.
*
* Lowercased on lookup because Google returns the address as the person typed it;
* the document id in `admins` must therefore always be lowercase. */
function adminRow() {
return get(/databases/$(database)/documents/admins/$(request.auth.token.email.lower())).data;
}
/** An admin is a member whose address has an ACTIVE row in `admins`.
*
* THE ACTIVE TEST IS NOT DECORATION. Retiring an organiser is a flag rather than a
* delete, because the roster is also the club's handover record and the row has to
* survive. Without this clause the row surviving meant the ACCESS surviving too: a
* retired organiser kept every permission on this page and only the screen stopped
* offering them the buttons. That is exactly the gap a UI-only check leaves.
*
* A MISSING FLAG MEANS ACTIVE, deliberately. Rows written before the field existed
* carry neither it nor a role, and defaulting to denied would have locked out every
* organiser the club already had, the moment these rules deployed. web/lib/auth.tsx
* applies the same two defaults; change one and you must change both. */
function isAdmin() {
return isStudent()
&& exists(/databases/$(database)/documents/admins/$(request.auth.token.email.lower()))
&& adminRow().get('active', true) == true;
}
/** An owner is an active admin whose row says so — the two or three people who may
* change the roster itself.
*
* TWO TIERS RATHER THAN ONE, because both alternatives are worse. Console-only
* appointment makes every addition wait on whoever holds console access, and that
* person eventually graduates. Letting any admin appoint any admin means one
* compromised college account can appoint accomplices and retire everybody else.
*
* A missing role means plain admin, so no rules deployment silently promotes
* anybody. */
function isOwner() {
return isAdmin() && adminRow().get('role', 'admin') == 'owner';
}
/** May this reader see the document being read? ONE FUNCTION, applied identically
* to announcements, forms and sessions, so the three cannot drift.
*
* READS resource.data DIRECTLY AND TAKES NO ARGUMENT, AND THAT IS A SECURITY FIX
* RATHER THAN A STYLE. The obvious way to write this is `d.get('audience',
* 'both')`, so that a document predating the field falls back to the old
* everyone-sees-it behaviour. That version is NOT ENFORCED ON A QUERY. Tested
* against the emulator, with two documents and a non-member:
*
* resource.data.audience == 'both' unfiltered list -> REFUSED
* resource.data.get('audience', 'both') == 'both' unfiltered list -> SUCCEEDED,
* returning the members-only row
*
* A per-document `get` is refused correctly under both forms; it is specifically
* the LIST path that leaks, which is the one every dashboard panel uses. The rule
* looked right, read right, and handed the club's private board to the whole
* college. Do not reintroduce a default here — if you need one, it belongs in a
* rule that is never evaluated against a query, the way canAnswerForm() below is.
*
* HOW A LIST IS ACTUALLY JUDGED, because it is not what it looks like. Firestore
* does not run this rule over the documents a query returns and drop the ones it
* refuses. It runs it against the QUERY, and allows it only if the query's own
* constraints PROVE the rule holds for every document that could possibly match.
* So `getDocs(collection(db, 'announcements'))` is refused for an ordinary reader
* even when every notice in the collection is addressed to everyone — nothing in
* an unconstrained query proves that, and tomorrow's notice might not be. Adding
* `where('audience', 'in', ['both', 'students'])` makes it provable, and the same
* query then succeeds. That is why every read in web/lib carries the clause, and
* why it cannot be dropped after checking that no members-only notice exists yet.
*
* It is also why the defaulted version leaked rather than merely being loose: a
* `.get(field, default)` expression is satisfiable whatever the document holds, so
* the prover waved through the unconstrained query it should have refused.
*
* THE COST OF NO DEFAULT is that a notice with no `audience` field at all is
* refused rather than shown to everyone. That is not a regression, because such a
* document is already unreachable: a where clause does not match documents missing
* the field, so no constrained query can return it either. They have to be stamped
* either way — components/AudienceBackfill.tsx finds them and says so on the
* organisers' page.
*
* ADMINS SEE EVERYTHING regardless, and that is not a loophole: they are the
* people writing these documents, and an organiser who could not see the notice
* they just posted because they aimed it at students would reasonably conclude the
* post had failed. It is also what keeps unstamped legacy documents visible to the
* only people who can fix them. */
function canSeeAudience() {
return isAdmin()
|| resource.data.audience == 'both'
|| (resource.data.audience == 'members' && isClubMember())
|| (resource.data.audience == 'students' && !isClubMember());
}
/** The same question asked about a form somebody is trying to ANSWER, rather than
* about the document being read.
*
* A SEPARATE FUNCTION BECAUSE IT IS SAFE TO BE TOLERANT HERE. This one is only
* ever evaluated on a single-document create or update against forms/{id}/
* responses/{uid} — never against a query — so the default that makes the read
* rule leak cannot leak anything here, and it is what lets a member still answer a
* form written before audiences existed. The argument is the FORM's data, fetched
* by formDoc(), not the response being written. */
function canAnswerForm(f) {
return isAdmin()
|| f.get('audience', 'both') == 'both'
|| (f.get('audience', 'both') == 'members' && isClubMember())
|| (f.get('audience', 'both') == 'students' && !isClubMember());
}
/** An audience field, as a writer may set it. Optional on the way in: a build that
* predates the picker omits it and gets the 'both' default, which is what those
* documents already meant. */
function isWellFormedAudience(d) {
return !('audience' in d)
|| d.audience in ['members', 'students', 'both'];
}
/** THE FIELDS A MEMBER MAY NOT TOUCH ON THEIR OWN PROFILE, and the reason
* membership can live on a document its subject owns.
*
* Everything else at users/{uid} is the member's to write — it is their name and
* their hostel. Membership is the club's statement about them, and it is read by
* isClubMember() to decide what they can see, so a member who could write it
* could admit themselves to the club and read the organisers' board. This is the
* rule that makes that impossible.
*
* changedKeys() RATHER THAN COMPARING VALUES, because the comparison has to hold
* for documents that do not carry the field at all: `resource.data.membership` on
* a profile written before this existed is an error, not a null, and an error in
* a rule denies the write. Asking whether the key changed is well defined whether
* or not it is there. */
function membershipUnchanged() {
return !request.resource.data.diff(resource.data).changedKeys()
.hasAny(['membership', 'membership_by', 'membership_at']);
}
/** An organiser admitting somebody to the club, or removing them — and NOTHING
* else in the same write.
*
* This is the only rule in the file that lets one person write another person's
* document, so it is deliberately the narrowest: exactly the three membership
* keys may differ, the actor is stamped from their own token rather than from the
* request body, and the time is the server's. An admin cannot use this path to
* edit somebody's name, hostel or GitHub — those stay the member's own.
*
* THE AUDIT FIELDS ARE REQUIRED, NOT OPTIONAL. "Who is in this club" is the
* question the organisers' page is opened with, and a club whose membership
* changes without a record of who changed it cannot answer the follow-up. Same
* reasoning as `added_by` on the roster. */
function onlyMembershipChanged() {
return request.resource.data.diff(resource.data).changedKeys()
.hasOnly(['membership', 'membership_by', 'membership_at'])
&& request.resource.data.membership in ['member', 'student']
&& request.resource.data.membership_by == request.auth.token.email
&& request.resource.data.membership_at == request.time;
}
/** Fields a member may never change after the first save. `created_at` is what
* "member since" is read from, and `email`/`uid` are the identity the whole model
* hangs on. */
function immutablesUnchanged() {
return request.resource.data.uid == resource.data.uid
&& request.resource.data.email == resource.data.email
&& request.resource.data.created_at == resource.data.created_at;
}
/** The profile shape. Mirrors web/lib/profile.ts and web/content/join.ts.
*
* EIGHT FIELDS HAVE BEEN REMOVED from this list over time — why, heard_from,
* interests, updates, year_branch, level, programs and programs_other — because each
* cost a member time at sign-up and nothing read it back. `hasOnly` is strict, so a
* document still carrying one of them is REJECTED rather than tolerated. That is safe
* only because no member profile exists in production yet. Once one does, removing a
* field means either leaving it in `hasOnly` or migrating the documents first — a
* strict list turns an old field into a member who can no longer edit their profile. */
function isWellFormedProfile(d, uid) {
return
// Exactly the expected fields. `hasOnly` is the load-bearing half: `hasAll`
// alone would let somebody append a hundred keys of their own — including, say,
// an `isAdmin` field that a future careless rule might read.
d.keys().hasOnly([
'uid', 'email', 'name', 'hostel', 'github', 'path',
'membership', 'membership_by', 'membership_at',
'created_at', 'updated_at'
])
&& d.keys().hasAll(['uid', 'email', 'name', 'hostel', 'updated_at'])
// MEMBERSHIP IS IN THE SHAPE BUT NOT IN THIS FUNCTION'S GIFT. It is listed
// here only so that `hasOnly` does not reject a profile that already carries
// it — a member editing their name sends a merge, and the merged document
// includes whatever an organiser wrote. WHO may set it is decided by the
// rules on users/{uid} below, not here: this function is called on the
// member's own writes and an admin's alike, so enforcing it here would either
// block the organiser or permit the member.
&& (!('membership' in d) || d.membership in ['member', 'student'])
&& (!('membership_by' in d)
|| (d.membership_by is string && d.membership_by.size() > 0))
// Identity cannot be forged. The document id, the uid inside it and the signed-in
// token must all agree, and the stored address must be the one that signed in —
// so a member cannot file a profile under somebody else's name or address.
//
// This is also what makes the DERIVED batch trustworthy: web/lib/batch.ts reads
// the year and branch out of this address, so pinning the address is what stops
// somebody claiming a batch they are not in.
&& d.uid == uid
&& d.uid == request.auth.uid
&& d.email == request.auth.token.email
// Required strings, bounded. The form's maxlength is a courtesy to the reader;
// these are the real limits, because a direct SDK call never sees the form.
&& d.name is string && d.name.size() > 0 && d.name.size() <= 120
// Optional strings. Present-or-absent rather than nullable, so an absent github
// unambiguously means "not given" — and a cleared one is sent as deleteField()
// rather than as "", which these size checks would refuse.
&& (!('github' in d) || (d.github is string && d.github.size() > 0 && d.github.size() <= 100))
// Closed sets — see the drift warning in the header.
&& d.hostel in ['uniworld-1', 'uniworld-2']
// `path` is optional: it is carried in from a ?path= link rather than asked for,
// and most members arrive without one.
&& (!('path' in d)
|| d.path in ['build-day', 'first-contribution', 'fast-track', 'program-track'])
// The server's clock, never the client's, so "member since" and "last edited"
// cannot be backdated.
&& d.updated_at == request.time
&& (!('created_at' in d) || d.created_at == request.time || d.created_at == resource.data.created_at);
}
/** A mentor entry, as an organiser writes it. Admin-only, but validated anyway: the
* shape is what the members' picker renders, and an admin fat-fingering a direct SDK
* call should not be able to put an unbounded string in front of every member. */
function isWellFormedMentor(d) {
return
d.keys().hasOnly([
'name', 'description', 'programme', 'org', 'github', 'email',
'active', 'created_at', 'updated_at'
])
&& d.keys().hasAll(['name', 'description', 'programme', 'active', 'updated_at'])
&& d.name is string && d.name.size() > 0 && d.name.size() <= 120
// The long one. 600 rather than 120 because this is the paragraph a student reads
// before choosing, and a mentor boundary worth stating does not fit in a tweet.
&& d.description is string && d.description.size() > 0 && d.description.size() <= 600
&& d.active is bool
&& (!('org' in d) || (d.org is string && d.org.size() > 0 && d.org.size() <= 120))
&& (!('github' in d) || (d.github is string && d.github.size() > 0 && d.github.size() <= 100))
&& (!('email' in d) || (d.email is string && d.email.size() > 0 && d.email.size() <= 160))
// The same closed set the profile used to carry for `programs`, kept because a
// mentor belongs to a named programme and `npm run rules` diffs it against
// PROGRAMS in web/content/join.ts.
&& d.programme in [
'gsoc', 'lfx', 'outreachy', 'sok', 'hacktoberfest', 'sob',
'gssoc', 'ssoc', 'esoc', 'other'
]
&& d.updated_at == request.time
&& (!('created_at' in d) || d.created_at == request.time || d.created_at == resource.data.created_at);
}
/** A member's mentor preferences.
*
* THE PAIRING IS THE INTERESTING PART, and it is checked in both directions for the
* same reason the old programs/programs_other pair was: a document carrying both a
* second choice and "first preference only" is contradictory, and one carrying
* neither is an unanswered question stored as though it were an answer. Exactly one
* of them, always.
*
* Both mentor ids must name a document that EXISTS. That costs one extra read per
* write and it is what stops the organisers' interest list from displaying a raw id
* where a name should be. */
function isWellFormedEnrollment(d, uid) {
return
d.keys().hasOnly([
'uid', 'email', 'programme', 'mentor_1', 'mentor_2', 'first_only',
'created_at', 'updated_at'
])
&& d.keys().hasAll(['uid', 'email', 'programme', 'mentor_1', 'first_only', 'updated_at'])
&& d.uid == uid
&& d.uid == request.auth.uid
&& d.email == request.auth.token.email
&& d.programme in [
'gsoc', 'lfx', 'outreachy', 'sok', 'hacktoberfest', 'sob',
'gssoc', 'ssoc', 'esoc', 'other'
]
&& d.first_only is bool
&& d.mentor_1 is string
&& d.mentor_1.size() > 0
&& d.mentor_1.size() <= 64
&& exists(/databases/$(database)/documents/mentors/$(d.mentor_1))
// "First preference only" means there is no second one.
&& (!d.first_only || !('mentor_2' in d))
// And not saying so means there must be.
&& (d.first_only || 'mentor_2' in d)
&& (!('mentor_2' in d)
|| (d.mentor_2 is string
&& d.mentor_2.size() > 0
&& d.mentor_2.size() <= 64
// The same mentor twice is not two preferences.
&& d.mentor_2 != d.mentor_1
&& exists(/databases/$(database)/documents/mentors/$(d.mentor_2))))
&& d.updated_at == request.time
&& (!('created_at' in d) || d.created_at == request.time || d.created_at == resource.data.created_at);
}
/** A roster row: who runs the club, and what that entitles them to.
*
* ONE ROW CARRIES BOTH HALVES — the access grant (role, active) that this file reads
* on every request, and the public billing (name, title, photo) the team page shows.
* They are one row because they were two lists and the two lists drifted: the club
* kept its team in a content file and its access here, nothing connected them, and
* the failure was silent in both directions.
*
* VALIDATED EVEN THOUGH ONLY OWNERS WRITE IT, and here that is not belt-and-braces:
* role and active are read straight back by isAdmin() and isOwner() above. A row whose
* role is the string "Owner" grants nothing and looks right in the console; a row whose
* active flag is the string "false" reads as active forever. The two fields that decide
* access are the two least safe to leave unchecked. */
function isWellFormedRosterRow(d, email) {
return
d.keys().hasOnly([
'email', 'name', 'title', 'photo', 'role', 'active', 'group',
'batch', 'github', 'shadow_of', 'added_by', 'added_at', 'updated_at'
])
&& d.keys().hasAll(['email', 'name', 'role', 'active', 'added_by', 'updated_at'])
// A row cannot grant access to one address while describing another. The id is what
// isAdmin() looks up and it lowercases before looking, so a row filed under a
// capitalised address would be a grant nobody could ever use.
&& d.email == email
&& d.email.lower() == d.email
&& d.email.matches('^[^@]+@sst[.]scaler[.]com$')
&& d.name is string && d.name.size() > 0 && d.name.size() <= 120
&& d.role in ['owner', 'admin']
&& d.active is bool
&& (!('title' in d) || (d.title is string && d.title.size() > 0 && d.title.size() <= 80))
&& (!('photo' in d) || (d.photo is string && d.photo.size() > 0 && d.photo.size() <= 300))
&& (!('group' in d) || d.group in ['officer', 'lead', 'shadow'])
&& (!('batch' in d) || (d.batch is string && d.batch.size() > 0 && d.batch.size() <= 12))
&& (!('github' in d) || (d.github is string && d.github.size() > 0 && d.github.size() <= 100))
&& (!('shadow_of' in d)
|| (d.shadow_of is string && d.shadow_of.size() > 0 && d.shadow_of.size() <= 80))
&& d.added_by is string && d.added_by.size() > 0
&& d.updated_at == request.time
&& (!('added_at' in d) || d.added_at == request.time || d.added_at == resource.data.added_at);
}
/** A notice on the board.
*
* THE LINK IS THE ONE FIELD WITH A HOLE IN IT, and it is closed here rather than in
* the client. This is free text an organiser types, and it renders as a link every
* member can click — so a `javascript:` href in it is a script running inside a
* signed-in member's page. Requiring the string to start https:// is what refuses
* that, and it belongs in this file because the client is a client. */
function isWellFormedPost(d) {
return
d.keys().hasOnly([
'title', 'body', 'link', 'pinned', 'category', 'archived',
'audience', 'author_email', 'created_at', 'updated_at'
])
&& isWellFormedAudience(d)
&& d.keys().hasAll(['title', 'body', 'pinned', 'author_email', 'updated_at'])
&& d.title is string && d.title.size() > 0 && d.title.size() <= 140
&& d.body is string && d.body.size() > 0 && d.body.size() <= 4000
&& d.pinned is bool
// Both optional, and both ABSENT on every notice posted before they existed. The
// rules must not require them: a strict list would have made every notice already
// on the board unsaveable the moment this deployed, which presents to an organiser
// as "the pin button is broken".
&& (!('archived' in d) || d.archived is bool)
&& (!('category' in d) || d.category in ['general', 'event', 'deadline'])
&& (!('link' in d)
|| (d.link is string
&& d.link.size() > 0
&& d.link.size() <= 500
&& d.link.matches('^https://[^ ]+$')))
&& d.author_email is string && d.author_email.size() > 0
&& d.updated_at == request.time
&& (!('created_at' in d) || d.created_at == request.time || d.created_at == resource.data.created_at);
}
/** A session: what the club is doing, and WHEN.
*
* starts_at IS THE ONE DATE IN THIS FILE THE CLIENT CHOOSES. Every other timestamp is
* pinned to request.time so nothing can be backdated. This one is nearly always in the
* future — that is what scheduling means — so the same rule would make the feature
* impossible. It is a time on a poster; nothing reads it as evidence of when something
* happened, and that is what makes the exception safe rather than an oversight. */
function isWellFormedSession(d) {
return
d.keys().hasOnly([
'title', 'speaker', 'location', 'notes', 'starts_at',
'audience', 'created_by', 'created_at', 'updated_at'
])
&& isWellFormedAudience(d)
&& d.keys().hasAll(['title', 'starts_at', 'created_by', 'updated_at'])
&& d.title is string && d.title.size() > 0 && d.title.size() <= 140
&& d.starts_at is timestamp
&& (!('speaker' in d) || (d.speaker is string && d.speaker.size() > 0 && d.speaker.size() <= 120))
&& (!('location' in d) || (d.location is string && d.location.size() > 0 && d.location.size() <= 120))
&& (!('notes' in d) || (d.notes is string && d.notes.size() > 0 && d.notes.size() <= 2000))
&& d.created_by is string && d.created_by.size() > 0
&& d.updated_at == request.time
&& (!('created_at' in d) || d.created_at == request.time || d.created_at == resource.data.created_at);
}
/** A form, which is also a poll — showing the counts back is the whole difference.
*
* field_ids IS A FLAT MIRROR OF THE QUESTIONS' IDS AND IT EXISTS FOR THIS FILE. Rules
* cannot iterate a list of maps, so there is no way to reach into `fields` and read an
* id out of it — which means no way to check that a member's answers use only keys the
* form actually asked. The mirror is the only expressible form of that check. Requiring
* the two lists to be the same LENGTH is what stops a mirror quietly ceasing to match
* the questions it mirrors. */
function isWellFormedForm(d) {
return
d.keys().hasOnly([
'title', 'description', 'fields', 'field_ids', 'open', 'show_tally',
'tally', 'audience', 'author_email', 'created_at', 'updated_at'
])
&& isWellFormedAudience(d)
&& d.keys().hasAll([
'title', 'fields', 'field_ids', 'open', 'show_tally', 'author_email', 'updated_at'
])
&& d.title is string && d.title.size() > 0 && d.title.size() <= 140
&& (!('description' in d)
|| (d.description is string && d.description.size() > 0 && d.description.size() <= 2000))
&& d.fields is list && d.fields.size() > 0 && d.fields.size() <= 30
&& d.field_ids is list && d.field_ids.size() == d.fields.size()
&& d.open is bool
&& d.show_tally is bool
&& d.author_email is string && d.author_email.size() > 0
&& d.updated_at == request.time
&& (!('created_at' in d) || d.created_at == request.time || d.created_at == resource.data.created_at);
}
/** The form a response is being filed against. One extra document read per write, and
* it buys the two things the response rules cannot do without: the question ids, and
* whether the form is still taking answers. */
function formDoc(formId) {
return get(/databases/$(database)/documents/forms/$(formId)).data;
}
/** One member's answer.
*
* ANSWER KEYS ARE PINNED TO THE FORM'S OWN QUESTION IDS. Without that a member could
* append a hundred keys of their own to a document the organisers later export — and
* an export is a spreadsheet somebody opens, not a thing anybody reviews field by
* field. The VALUES cannot be typed here, because rules cannot iterate a map's values.
* A bounded set of keys is the half that is expressible, and it is the half that
* matters.
*
* submitted_at IS DELIBERATELY NOT FROZEN. Nothing depends on it being trustworthy —
* it is a courtesy line on the organisers' screen — and freezing it would oblige a
* member changing their answer to send back a value they were never shown. */
function isWellFormedResponse(d, formId, uid) {
return
d.keys().hasOnly(['uid', 'email', 'name', 'answers', 'submitted_at', 'updated_at'])
&& d.keys().hasAll(['uid', 'email', 'answers', 'updated_at'])
&& d.uid == uid
&& d.uid == request.auth.uid
&& d.email == request.auth.token.email
&& (!('name' in d) || (d.name is string && d.name.size() > 0 && d.name.size() <= 120))
&& d.answers is map
&& d.answers.keys().hasOnly(formDoc(formId).field_ids)
&& d.updated_at == request.time;
}
/** AN APPLICATION FROM A STRANGER, and the only unauthenticated write in this file.
*
* WHY IT IS ANONYMOUS, restated because the obvious "improvement" is to require
* sign-in and it would be wrong. The club's front door cannot require the key you get
* by walking through it: the site's headline promises a reader they need nothing but a
* laptop and a GitHub account, and an auth wall on the apply form makes that sentence
* false at the exact moment somebody acts on it. web/lib/applications.ts says the same
* thing at greater length. A member's profile is a different act by a different person
* and lives at users/{uid}.
*
* SO THIS FUNCTION IS THE ENTIRE BOUNDARY. There is no uid to compare, no verified
* address, nothing to prove ownership with — every field here was typed by somebody
* the club has never met. `hasOnly` is the load-bearing half: without it a submitter
* appends a hundred keys of their own to a row an organiser later reads.
*
* WHAT STOPS A SCRIPT FILLING IT OVERNIGHT is App Check, not this file — see the note
* in web/lib/firebase.ts. Rules can say what a valid application looks like; they
* cannot say how many a stranger may send.
*
* THE CLOSED SETS MIRROR web/content/join.ts AND HAVE ALREADY DRIFTED ONCE. The
* version of this rule recovered from git accepted `level` values of none/some-git/
* merged; the form has offered beginner/intermediate since upstream replaced the
* LEVELS array with LEVEL_LABEL. Deployed as it was, it would have refused every real
* application while looking perfectly correct. `npm run rules` now diffs all four of
* these sets against the content. */
// isWellFormedApplication WAS HERE. It validated the anonymous application form's
// shape and was the entire boundary on a collection strangers could write to. With
// the form gone and create denied, it guarded nothing — and an unused validator on a
// sealed collection is an invitation to reopen the collection by deleting one line.
// ---------------------------------------------------------------- members
match /users/{uid} {
// GET and LIST are separated on purpose. A single `read` rule using
// `request.auth.uid == uid` cannot express "admins may query the collection":
// Firestore evaluates a list against the rule without knowing each document, so
// the owner clause would make every query fail. Splitting them gives members
// their own row and admins the whole table, with no rule that is true for both.
allow get: if isStudent() && (request.auth.uid == uid || isAdmin());
allow list: if isAdmin();
// First save. Only for yourself, and only a valid profile.
//
// NOBODY JOINS THE CLUB BY SIGNING UP. A first profile may not carry any of the
// membership keys at all, so the one document a student creates unsupervised
// cannot be the document that admits them. They become a member when an
// organiser says so, on the admin path below.
allow create: if isStudent()
&& request.auth.uid == uid
&& isWellFormedProfile(request.resource.data, uid)
&& !request.resource.data.keys()
.hasAny(['membership', 'membership_by', 'membership_at']);
// Edits, by the member themselves. Same validation, plus identity and created_at
// frozen, plus membership out of reach — see membershipUnchanged().
allow update: if isStudent()
&& request.auth.uid == uid
&& isWellFormedProfile(request.resource.data, uid)
&& immutablesUnchanged()
&& membershipUnchanged();
// Edits by an ORGANISER, and only ever to membership.
//
// A SECOND `allow update` RATHER THAN AN `||` INSIDE THE FIRST, because the two
// are different transactions with different validation: the member's write is
// checked against the whole profile shape and pinned to their own uid and
// address, and neither of those can hold for an admin writing somebody else's
// row. Firestore ORs the allow rules for us, so splitting them keeps each one
// readable on its own — and neither can be loosened by accident while trying to
// fix the other.
allow update: if isAdmin()
&& onlyMembershipChanged();
// Nobody deletes a profile from a client, including its owner and including
// admins. A member asking to be removed is a conversation and a console action,
// not a button — and it keeps an admin session from being able to wipe the roster.
allow delete: if false;
}
// ----------------------------------------------------------------- admins
match /admins/{email} {
// You may read exactly your OWN row, which is how the client discovers whether to
// show the dashboard link and whether to enable the roster form. Scoped to the
// caller's address, so a member cannot use this to find out who the organisers are.
allow get: if isStudent() && request.auth.token.email.lower() == email;
// THE WHOLE LIST, TO ADMINS ONLY. This is what the roster panel renders, and every
// admin sees it — read-only unless they are an owner, which is a decision the screen
// makes and this file does not need to. It stays denied to ordinary members because
// the document id IS an email: listing it would hand any member every organiser's
// inbox, which is also why the public team page is generated at build time from
// these rows rather than reading them live.
allow list: if isAdmin();
// OWNERS WRITE THE ROSTER. This is the one privilege escalation this model has to
// permit in order to be usable at all — a club whose team turns over yearly cannot
// route every appointment through whoever still has console access. Three fences
// keep the widening as narrow as it was meant to be:
//
// 1. isOwner(), so a plain admin — and therefore one compromised admin account —
// cannot appoint accomplices or retire anybody.
// 2. NOT YOUR OWN ROW, below. It stops an owner demoting themselves into a state
// only another owner can undo, and it means seizing the club takes two
// compromised owner accounts rather than one: whoever is left can always turn
// the others back on.
// 3. No delete, at all — see below.
//
// WHAT THIS STILL DOES NOT STOP: an owner retiring every other owner one at a time.
// Rules cannot count documents, so "never leave the club with no owner" is not
// expressible here; the roster screen shows the live owner count instead. The
// consequence is recoverable from the console, which is the test that made it
// acceptable to leave.
allow create: if isOwner()
&& request.auth.token.email.lower() != email
&& isWellFormedRosterRow(request.resource.data, email);
// WHEN SOMEBODY WAS APPOINTED CANNOT BE ERASED BY AN EDIT. Stated separately from
// the shape check because the shape check has to tolerate the field being ABSENT —
// every row seeded from the console before it existed has no appointment date, and
// requiring one would make those rows uneditable. This says the narrower thing: if
// the stored row HAS a date, the write must carry the same one back. Without it, a
// full overwrite that simply left the field out would silently wipe it, which is
// exactly the bug the profile rules already learned once.
allow update: if isOwner()
&& request.auth.token.email.lower() != email
&& isWellFormedRosterRow(request.resource.data, email)
&& (!('added_at' in resource.data)
|| request.resource.data.added_at == resource.data.added_at);
// Retiring is a flag, never a delete, and the difference is the point. It revokes
// just as fast — isAdmin() reads the flag on every request — and it keeps the record
// of who ran the club when, which is the thing a handover actually needs. It is also
// undoable by any other owner, which the fence above depends on.
allow delete: if false;
}
// ---------------------------------------------------------------- mentors
match /mentors/{id} {
// Every signed-in member may read the whole list — it is what the picker on the
// dashboard renders, and the list itself is not a secret. `list` is allowed here
// where it is admin-only on users/, because the difference is what the documents
// contain: published copy about a volunteer, versus a roster of students.
//
// Hidden mentors (active: false) come back too, deliberately. The client filters
// them out of the picker, but a member who picked somebody before they were hidden
// still needs to see a name rather than an id.
allow get, list: if isStudent();
// ADMIN WRITE. See the note in the header for why this widening is acceptable and
// where the same argument stops applying.
allow create, update: if isAdmin() && isWellFormedMentor(request.resource.data);
// Deleting is allowed, and the guard against orphaning a preference lives in the
// client — see web/lib/mentorship.ts. Rules cannot express "no document in another
// collection points at this one", because that needs a query and rules cannot
// query. The consequence of getting it wrong is cosmetic (an id shows where a name
// should) rather than a disclosure, which is why it is acceptable to enforce it
// one level up rather than here.
allow delete: if isAdmin();
}
// ------------------------------------------------------------ enrollments
match /enrollments/{uid} {
// Same get/list split, and for the same reason as users/: a single `read` rule
// naming the owner cannot express "and admins may query the collection", because
// Firestore evaluates a list without knowing the documents.
allow get: if isStudent() && (request.auth.uid == uid || isAdmin());
allow list: if isAdmin();
allow create: if isStudent()
&& request.auth.uid == uid
&& isWellFormedEnrollment(request.resource.data, uid);
allow update: if isStudent()
&& request.auth.uid == uid
&& isWellFormedEnrollment(request.resource.data, uid)
&& request.resource.data.uid == resource.data.uid
&& request.resource.data.email == resource.data.email
&& request.resource.data.created_at == resource.data.created_at;
// A MEMBER MAY WITHDRAW, which users/{uid} deliberately forbids. The two documents
// mean different things: a profile is the club's roster and deleting one loses a
// member, whereas this is an expression of interest and taking it back is the
// member's own decision — putting that behind an email to an organiser would be
// the site making somebody ask permission to change their mind.
//
// Owner only. An admin cannot delete somebody's enrollment, for the same reason
// they cannot delete a profile.
allow delete: if isStudent() && request.auth.uid == uid;
}
// ---------------------------------------------------------- announcements
match /announcements/{id} {
// EVERY MEMBER LISTS THE BOARD, and that is the exception in this file rather than
// the pattern. users/ is admin-only to list because every row is somebody's address;
// a notice carries nothing personal beyond the organiser's byline. If a field is ever
// added here that names a member, this rule is the first thing to revisit.
//
// Archived notices come back too. The organisers' screen has to show them — an
// archive nobody can see is a delete with extra steps — so the member view filters
// them out in the client.
// AUDIENCE IS ENFORCED HERE, NOT IN THE PANEL THAT RENDERS IT. A members-only
// notice is withheld from a non-member by the database; the screen never sees it
// to filter.
//
// EVERY CLIENT READ OF THIS COLLECTION MUST CARRY where("audience", "in", ...),
// built from queryableAudiences() in web/lib/audience.ts. That is not defensive
// duplication — a list rule is judged against the QUERY rather than the rows it
// returns, so an unconstrained read is refused outright, even when every document
// in the collection would have been allowed. See canSeeAudience() above for the
// full note. If a panel goes blank for members but not for organisers, a missing
// where clause is the first thing to check.
allow get, list: if isStudent() && canSeeAudience();
// THE BYLINE IS PINNED ON CREATE AND FROZEN ON EDIT, which are two different rules
// for a reason that is easy to get wrong. Pinning it on edit as well would mean any
// admin pinning somebody else's notice silently signs their name to it; freezing it
// on create would mean pinning it to nothing at all.
allow create: if isAdmin()
&& request.resource.data.author_email == request.auth.token.email
&& isWellFormedPost(request.resource.data);
allow update: if isAdmin()
&& request.resource.data.author_email == resource.data.author_email
&& isWellFormedPost(request.resource.data)
// WHEN THE NOTICE WENT UP CANNOT BE ERASED BY AN EDIT, stated here rather than in
// the shape check for the same reason as the roster's appointment date: the shape
// check has to tolerate the field being absent, because a create has no stored
// value to compare against. This says the narrower thing — if the notice HAS a
// date, the write must carry the same one back.
//
// It is load-bearing because setFlags() in web/lib/announcements.ts is a FULL
// overwrite, not a merge: pressing "pin" sends the whole document, so a version
// of that function which forgot to include created_at would silently wipe the
// posting date of every notice anybody pinned. Its own comment claims this rule
// exists; before this line, it did not.
&& (!('created_at' in resource.data)
|| request.resource.data.created_at == resource.data.created_at);
// Deletable, unlike a profile: a notice with the wrong date has to be retractable and
// there is no roster to lose. Archiving is the ordinary way one comes off the board;
// this is for the one posted by mistake.
allow delete: if isAdmin();
}
// --------------------------------------------------------------- sessions
match /sessions/{id} {
// AUDIENCE IS ENFORCED HERE, NOT IN THE PANEL THAT RENDERS IT. A members-only
// notice is withheld from a non-member by the database; the screen never sees it
// to filter.
//
// EVERY CLIENT READ OF THIS COLLECTION MUST CARRY where("audience", "in", ...),
// built from queryableAudiences() in web/lib/audience.ts. That is not defensive
// duplication — a list rule is judged against the QUERY rather than the rows it
// returns, so an unconstrained read is refused outright, even when every document
// in the collection would have been allowed. See canSeeAudience() above for the
// full note. If a panel goes blank for members but not for organisers, a missing
// where clause is the first thing to check.
allow get, list: if isStudent() && canSeeAudience();
// created_by gets the same pin-then-freeze treatment as a notice's byline, and for
// the same reason: editing somebody's session must not claim it.
allow create: if isAdmin()
&& request.resource.data.created_by == request.auth.token.email
&& isWellFormedSession(request.resource.data);
allow update: if isAdmin()
&& request.resource.data.created_by == resource.data.created_by
&& isWellFormedSession(request.resource.data)
// Same freeze as a notice's posting date, and saveSession() is a full overwrite
// too — moving a session must not erase when it was first scheduled.
&& (!('created_at' in resource.data)
|| request.resource.data.created_at == resource.data.created_at);
// A real delete, unlike a notice's archive. A session carries nobody's answer, so
// nothing is lost — and a cancelled session that cannot be taken off the list is the
// club telling its members to turn up to a room nobody booked.
allow delete: if isAdmin();
}
// ------------------------------------------------------------------ forms
match /forms/{formId} {
// Members read every form, including closed ones, because the dashboard shows a
// member what they already answered after it closes. Note that `tally` therefore
// reaches every member regardless of the show-the-counts flag: that flag is a
// rendering decision, not a boundary, and it is safe to leave as one only because a
// tally is aggregate counts and never says who answered what. Attributed answers
// live in the subcollection below, and that IS fenced.
// AUDIENCE IS ENFORCED HERE, NOT IN THE PANEL THAT RENDERS IT. A members-only
// notice is withheld from a non-member by the database; the screen never sees it
// to filter.
//
// EVERY CLIENT READ OF THIS COLLECTION MUST CARRY where("audience", "in", ...),
// built from queryableAudiences() in web/lib/audience.ts. That is not defensive
// duplication — a list rule is judged against the QUERY rather than the rows it
// returns, so an unconstrained read is refused outright, even when every document
// in the collection would have been allowed. See canSeeAudience() above for the
// full note. If a panel goes blank for members but not for organisers, a missing
// where clause is the first thing to check.
allow get, list: if isStudent() && canSeeAudience();
// THE TALLY IS REFUSED TO EVERY CLIENT, and this is the same reasoning as the GitHub
// counts: a count the client supplies is a count the client invented. It is written
// only by the tallyResponses Cloud Function through the Admin SDK, which does not
// pass through this file at all.
//
// On create it must be absent. On update it must be absent or IDENTICAL to what is
// stored — absent is allowed because a form nobody has answered has no tally, and
// identical is what lets an organiser fix a typo in a poll that already has votes
// without the write being refused. web/lib/forms.ts sends the stored value straight
// back for exactly that case.
allow create: if isAdmin()
&& request.resource.data.author_email == request.auth.token.email
&& !('tally' in request.resource.data)
&& isWellFormedForm(request.resource.data);
allow update: if isAdmin()
&& request.resource.data.author_email == resource.data.author_email
&& (!('tally' in request.resource.data)
|| request.resource.data.tally == resource.data.tally)
&& isWellFormedForm(request.resource.data)
// Same freeze as a notice's posting date. saveForm() is a full overwrite as well,
// so without this, correcting a question would erase when the form was published.
&& (!('created_at' in resource.data)
|| request.resource.data.created_at == resource.data.created_at);
allow delete: if isAdmin();
}
match /forms/{formId}/responses/{uid} {
// ONE ANSWER PER MEMBER, BY CONSTRUCTION — keyed by uid, the same trick as users/.
// A second response is not refused by a check somebody has to remember to write; it
// cannot be expressed.
//
// THE LIST RULE IS THE ONE THAT MATTERS HERE. Responses are attributed, which is the
// point — most of these are "sign up for X" and somebody has to chase the people who
// did not — and that is only safe because no member can enumerate them. Loosen this
// and the form system becomes a public one, silently, with every answer already in it.
allow get: if isStudent() && (request.auth.uid == uid || isAdmin());
allow list: if isAdmin();
// A CLOSED FORM TAKES NO MORE ANSWERS. Enforced here rather than by hiding the
// button, because the button is a rendering decision — and "closed" is the only
// thing an organiser has to stop a sign-up once the room is full.
//
// THE AUDIENCE TEST IS REPEATED ON THE WAY IN, and it is not redundant with the
// one on the form itself. Answering is a separate request from reading: a
// non-member who is refused the members-only form can still POST a response to
// its id, because the id is not a secret and this subcollection is a different
// path with its own rules. Without this clause the club's private sign-ups would
// be open to the whole domain to fill in — the read gate is not a write gate,
// and a form nobody outside the club may SEE must also be one nobody outside it
// may ANSWER.
allow create: if isStudent()
&& request.auth.uid == uid