Repository navigation
Expand file tree
/
Copy pathfirestore.rules
More file actions
182 lines (159 loc) · 8.09 KB
/
Copy pathfirestore.rules
File metadata and controls
182 lines (159 loc) · 8.09 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
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function signedIn() {
return request.auth != null;
}
match /users/{uid} {
allow read: if signedIn();
allow write: if signedIn() && request.auth.uid == uid;
}
// No Cloud Functions on the Spark plan — approved renames/zones are
// written directly by the confirming client, gated below on the source
// proposal already being "approved" (flipped by the proposals update
// rule once confirmCount crosses CONFIRMATION_THRESHOLD).
match /placeOverrides/{placeId} {
allow read: if signedIn();
allow create, update: if signedIn()
&& request.resource.data.sourceProposalId is string
&& get(/databases/$(database)/documents/proposals/$(request.resource.data.sourceProposalId)).data.status == 'approved'
&& get(/databases/$(database)/documents/proposals/$(request.resource.data.sourceProposalId)).data.payload.placeId == placeId;
allow delete: if false;
}
match /constructionZones/{zoneId} {
allow read: if signedIn();
// Doc ID == source proposal ID, so no payload cross-check is needed.
allow create: if signedIn()
&& get(/databases/$(database)/documents/proposals/$(zoneId)).data.status == 'approved'
&& get(/databases/$(database)/documents/proposals/$(zoneId)).data.type == 'construction-zone';
allow update, delete: if false;
}
match /proposals/{proposalId} {
allow read: if signedIn();
allow create: if signedIn()
&& request.resource.data.submittedBy == request.auth.uid
&& request.resource.data.status == 'pending'
&& request.resource.data.confirmCount == 0;
// Confirming a proposal (client-side transaction, see
// proposalsRepository.ts) increments confirmCount by exactly 1 and may
// flip status to "approved" once the count crosses
// CONFIRMATION_THRESHOLD (hardcoded below as 10 — rules can't import
// the TS constant in constants/proposals.ts, keep both in sync).
allow update: if signedIn()
&& resource.data.status == 'pending'
&& resource.data.submittedBy != request.auth.uid
&& request.resource.data.confirmCount == resource.data.confirmCount + 1
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['confirmCount', 'status', 'approvedAt'])
&& (
(request.resource.data.confirmCount < 10 && request.resource.data.status == 'pending') ||
(request.resource.data.confirmCount >= 10 && request.resource.data.status == 'approved')
);
allow delete: if false;
match /confirmations/{uid} {
allow read: if signedIn();
// Doc ID == uid, so a repeat attempt targets an existing document —
// Firestore treats that as an update, which is disallowed below,
// giving idempotent "one vote per distinct uid" counting for free.
// Self-confirmation is blocked here too.
allow create: if signedIn()
&& request.auth.uid == uid
&& get(/databases/$(database)/documents/proposals/$(proposalId)).data.submittedBy != request.auth.uid;
allow update, delete: if false;
}
}
// Accessibility reports are visible immediately on submission (unlike
// proposals/construction zones, which wait on confirmation votes) — a
// broken elevator matters now, not once a threshold is crossed.
// `expiresAt` lets stale, unconfirmed reports stop being treated as
// active client-side (see services/accessibility/reportConfidence.ts) —
// there's no Cloud Functions on the Spark plan to expire them server-side,
// so this is deliberately a pure read-time computation, not a write.
match /accessibilityReports/{reportId} {
allow read: if signedIn();
allow create: if signedIn()
&& request.resource.data.submittedBy == request.auth.uid
&& request.resource.data.status == 'active'
&& request.resource.data.severity in ['low', 'medium', 'high']
&& request.resource.data.confirmCount == 0
&& request.resource.data.fixedCount == 0;
// Three shapes, never combined in one write:
// - "still an issue" (confirmCount bump + freshness refresh, alongside
// a confirmations/{uid} doc — same idempotency trick as proposals).
// - "fixed" (fixedCount bump + freshness refresh, alongside a
// fixedVotes/{uid} doc), optionally resolving the report once enough
// users agree it's fixed.
// - a direct resolve (status + resolvedAt only).
allow update: if signedIn()
&& (
(request.resource.data.diff(resource.data).affectedKeys().hasOnly(['confirmCount', 'lastConfirmedAt', 'expiresAt'])
&& request.resource.data.confirmCount == resource.data.confirmCount + 1)
||
(request.resource.data.diff(resource.data).affectedKeys().hasOnly(['fixedCount', 'lastConfirmedAt'])
&& request.resource.data.fixedCount == resource.data.fixedCount + 1)
||
(request.resource.data.diff(resource.data).affectedKeys().hasOnly(['fixedCount', 'lastConfirmedAt', 'status', 'resolvedAt'])
&& request.resource.data.fixedCount == resource.data.fixedCount + 1
&& request.resource.data.status == 'resolved')
||
(request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'resolvedAt'])
&& request.resource.data.status == 'resolved')
);
allow delete: if false;
match /confirmations/{uid} {
allow read: if signedIn();
allow create: if signedIn() && request.auth.uid == uid;
allow update, delete: if false;
}
match /fixedVotes/{uid} {
allow read: if signedIn();
allow create: if signedIn() && request.auth.uid == uid;
allow update, delete: if false;
}
}
// Discover feed posts (events people share, restaurant deals,
// announcements) — a casual community feed, visible immediately on
// submission like accessibility reports, not gated behind confirmations.
match /discoverPosts/{postId} {
allow read: if signedIn();
allow create: if signedIn()
&& request.resource.data.submittedBy == request.auth.uid
&& request.resource.data.upvotes == 0
&& request.resource.data.placeId is string
&& request.resource.data.placeId.size() > 0;
// Only an upvote bump (handled transactionally alongside an
// upvotes/{uid} doc, same idempotency trick as proposals/reports above).
allow update: if signedIn()
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['upvotes'])
&& request.resource.data.upvotes == resource.data.upvotes + 1;
allow delete: if false;
match /upvotes/{uid} {
allow read: if signedIn();
allow create: if signedIn() && request.auth.uid == uid;
allow update, delete: if false;
}
}
// Cache of a public, third-party events feed (Georgetown's LiveWhale
// calendar — see services/events/georgetown/GeorgetownLiveWhaleProvider.ts).
// Deliberately NOT the submittedBy/request.auth.uid ownership model used
// everywhere else in this file: this isn't user-submitted content, it's
// a mirror of publicly-readable calendar data with no per-user author.
// Any signed-in client may refresh it — there's no Cloud Functions on
// the Spark plan to do that on a schedule server-side, so whichever
// client's app load finds the cache stale performs the refresh. Shape
// is checked, not attribution.
match /events/{eventId} {
allow read: if signedIn();
allow create, update: if signedIn()
&& request.resource.data.campusId is string
&& request.resource.data.title is string
&& request.resource.data.startAt is string
&& request.resource.data.source is string;
allow delete: if false;
}
match /eventsCacheMeta/{campusId} {
allow read: if signedIn();
allow create, update: if signedIn() && request.resource.data.source is string;
allow delete: if false;
}
}
}