Repository navigation
Expand file tree
/
Copy pathfirestore.rules
More file actions
297 lines (260 loc) · 14.2 KB
/
Copy pathfirestore.rules
File metadata and controls
297 lines (260 loc) · 14.2 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
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// --- Helper Functions ---
function isSignedIn() {
return request.auth != null;
}
function isOwner(userId) {
return request.auth.uid == userId;
}
// Platform admin, granted via the Firebase custom claim `admin: true`.
// Set out-of-band by the grant-admin script (Admin SDK) — clients can't
// self-grant because the claim lives on the auth token, not Firestore.
function isAdmin() {
return request.auth != null && request.auth.token.admin == true;
}
match /users/{userId} {
// Owner: full access to their own user doc and subcollections.
allow read, write: if (isSignedIn() && isOwner(userId)) || (request.auth != null && request.auth.token.admin == true);
// Teacher of this student's classroom: can READ the student's user doc
// (so the dashboard can show name, role, classId, unlockedCourseIds).
allow read: if isSignedIn()
&& resource.data.classId is string
&& exists(/databases/$(database)/documents/classrooms/$(resource.data.classId))
&& get(/databases/$(database)/documents/classrooms/$(resource.data.classId)).data.teacherId == request.auth.uid;
// Teacher of this student's classroom: can UPDATE ONLY the
// unlockedCourseIds field on the student's user doc (per-student
// course access overrides). Nothing else.
allow update: if isSignedIn()
&& resource.data.classId is string
&& exists(/databases/$(database)/documents/classrooms/$(resource.data.classId))
&& get(/databases/$(database)/documents/classrooms/$(resource.data.classId)).data.teacherId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['unlockedCourseIds']);
// Teacher of this student's classroom: can CLEAR the student's classId
// (kick out / remove from class). Only that one field, only to empty
// string. Prevents accidental writes to anything else.
allow update: if isSignedIn()
&& resource.data.classId is string
&& exists(/databases/$(database)/documents/classrooms/$(resource.data.classId))
&& get(/databases/$(database)/documents/classrooms/$(resource.data.classId)).data.teacherId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['classId'])
&& request.resource.data.classId == '';
// Classmates: a signed-in user can READ another user's doc when both
// share a classId (i.e. they're in the same classroom). Powers the
// class-scoped leaderboard. Costs a single get() per read.
allow read: if isSignedIn()
&& resource.data.classId is string
&& exists(/databases/$(database)/documents/users/$(request.auth.uid))
&& get(/databases/$(database)/documents/users/$(request.auth.uid)).data.classId == resource.data.classId;
// Schoolmates: a signed-in user can READ another user's doc when both
// share a non-empty schoolId. Powers the school-scoped leaderboard.
// Costs a single get() per read.
allow read: if isSignedIn()
&& resource.data.schoolId is string
&& resource.data.schoolId != ''
&& exists(/databases/$(database)/documents/users/$(request.auth.uid))
&& get(/databases/$(database)/documents/users/$(request.auth.uid)).data.schoolId == resource.data.schoolId;
// School registrar: can READ user docs of users whose schoolId points to
// a school they registered. Powers the "pending students" queue.
allow read: if isSignedIn()
&& resource.data.schoolId is string
&& resource.data.schoolId != ''
&& exists(/databases/$(database)/documents/schools/$(resource.data.schoolId))
&& get(/databases/$(database)/documents/schools/$(resource.data.schoolId)).data.registrarId == request.auth.uid;
// School registrar: APPROVE a pending join — set schoolJoinPending=false
// on a user whose schoolId points to a school the caller registered.
// Only that one field, only to false. Cannot touch other fields.
allow update: if isSignedIn()
&& resource.data.schoolId is string
&& resource.data.schoolId != ''
&& exists(/databases/$(database)/documents/schools/$(resource.data.schoolId))
&& get(/databases/$(database)/documents/schools/$(resource.data.schoolId)).data.registrarId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['schoolJoinPending'])
&& request.resource.data.schoolJoinPending == false;
// School registrar: REJECT a pending join — set schoolJoinPending=false
// AND clear schoolId to ''. Only those two fields, with those exact
// values. Lets the registrar push the user back to "no school" state.
allow update: if isSignedIn()
&& resource.data.schoolId is string
&& resource.data.schoolId != ''
&& exists(/databases/$(database)/documents/schools/$(resource.data.schoolId))
&& get(/databases/$(database)/documents/schools/$(resource.data.schoolId)).data.registrarId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['schoolJoinPending', 'schoolId'])
&& request.resource.data.schoolJoinPending == false
&& request.resource.data.schoolId == '';
// Subcollections (progress, economy, etc.):
match /{document=**} {
// Owner: full access.
allow read, write: if (isSignedIn() && isOwner(userId)) || (request.auth != null && request.auth.token.admin == true);
// Teacher of the user's classroom: read-only (dashboard needs lessons + stars).
allow read: if isSignedIn()
&& exists(/databases/$(database)/documents/users/$(userId))
&& get(/databases/$(database)/documents/users/$(userId)).data.classId is string
&& exists(/databases/$(database)/documents/classrooms/$(get(/databases/$(database)/documents/users/$(userId)).data.classId))
&& get(/databases/$(database)/documents/classrooms/$(get(/databases/$(database)/documents/users/$(userId)).data.classId)).data.teacherId == request.auth.uid;
}
}
// --- Legacy / Root Support ---
// Keeps support for any direct root-level collections if still in use
// --- Username Registry ---
// Specifically for unique username lookups
match /leaderboard/{docId} {
// Publicly readable
allow read: if true;
// Writing is handled via Cloud Functions in production,
// but if writing from client, strictly limit to the user's entry
allow write: if request.auth.token.admin == true;
}
// --- Classrooms ---
// Teachers can create and update their own classroom.
// Students can read a classroom (to see the join code screen) and join it
// by adding themselves to studentIds — but cannot modify anything else.
match /classrooms/{classId} {
// Any signed-in user can read a classroom (needed to look up join codes)
allow read: if isSignedIn();
// Only the teacher who owns the classroom can create or fully update it
allow create: if isSignedIn() && request.resource.data.teacherId == request.auth.uid;
allow update: if isSignedIn() && (
// Teacher can update anything about their classroom
resource.data.teacherId == request.auth.uid
||
// Students can append their own uid to studentIds (join)
(
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['studentIds'])
&& request.resource.data.studentIds.hasAll(resource.data.studentIds)
&& request.resource.data.studentIds.size() == resource.data.studentIds.size() + 1
&& request.auth.uid in request.resource.data.studentIds
)
||
// Students can remove themselves from studentIds (leave)
(
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['studentIds'])
&& resource.data.studentIds.hasAll(request.resource.data.studentIds)
&& resource.data.studentIds.size() == request.resource.data.studentIds.size() + 1
&& request.auth.uid in resource.data.studentIds
&& !(request.auth.uid in request.resource.data.studentIds)
)
);
// Only the owning teacher can delete a classroom
allow delete: if isSignedIn() && resource.data.teacherId == request.auth.uid;
// --- Assignments subcollection ---
// Stored under classrooms/{classId}/assignments/{assignmentId}.
// Only the classroom's teacher can create/update/delete assignments.
// Members of the classroom (teacher + students) can read.
match /assignments/{assignmentId} {
function classroomData() {
return get(/databases/$(database)/documents/classrooms/$(classId)).data;
}
function isClassTeacher() {
return isSignedIn() && classroomData().teacherId == request.auth.uid;
}
function isClassMember() {
return isSignedIn() && (
classroomData().teacherId == request.auth.uid
|| request.auth.uid in classroomData().studentIds
);
}
allow read: if isClassMember();
allow create, update, delete: if isClassTeacher();
// Comments on this assignment.
match /comments/{commentId} {
function isClassTeacherC() {
return isSignedIn() && get(/databases/$(database)/documents/classrooms/$(classId)).data.teacherId == request.auth.uid;
}
function isClassMemberC() {
return isSignedIn() && (
get(/databases/$(database)/documents/classrooms/$(classId)).data.teacherId == request.auth.uid
|| request.auth.uid in get(/databases/$(database)/documents/classrooms/$(classId)).data.studentIds
);
}
allow read: if isClassMemberC();
allow create: if isClassMemberC() && request.resource.data.authorId == request.auth.uid;
allow delete: if isSignedIn() && (resource.data.authorId == request.auth.uid || isClassTeacherC());
// Comments are write-once — no edits.
allow update: if false;
}
}
// --- Posts (teacher announcements / classroom stream) ---
// Stored under classrooms/{classId}/posts/{postId}.
// Only the classroom's teacher can post or delete.
// Members of the classroom (teacher + students) can read.
match /posts/{postId} {
function classroomData() {
return get(/databases/$(database)/documents/classrooms/$(classId)).data;
}
function isClassTeacher() {
return isSignedIn() && classroomData().teacherId == request.auth.uid;
}
function isClassMember() {
return isSignedIn() && (
classroomData().teacherId == request.auth.uid
|| request.auth.uid in classroomData().studentIds
);
}
allow read: if isClassMember();
allow create, update, delete: if isClassTeacher();
// Comments on this post.
match /comments/{commentId} {
function isClassTeacherC() {
return isSignedIn() && get(/databases/$(database)/documents/classrooms/$(classId)).data.teacherId == request.auth.uid;
}
function isClassMemberC() {
return isSignedIn() && (
get(/databases/$(database)/documents/classrooms/$(classId)).data.teacherId == request.auth.uid
|| request.auth.uid in get(/databases/$(database)/documents/classrooms/$(classId)).data.studentIds
);
}
allow read: if isClassMemberC();
allow create: if isClassMemberC() && request.resource.data.authorId == request.auth.uid;
allow delete: if isSignedIn() && (resource.data.authorId == request.auth.uid || isClassTeacherC());
allow update: if false;
}
}
}
// --- Schools ---
// Code2Coder's school registry, gated by an admin-approval workflow.
// GET: single-doc fetch by id is public — a school's name/location
// isn't sensitive, and members need to resolve their schoolId
// regardless of its review status. Discoverability is controlled
// by LIST, not GET.
// LIST: only approved schools are publicly enumerable. A registrar can
// list their own (any status); admins can list anything. Firestore
// validates the query constraints against this rule, so the public
// list MUST filter status=='approved' and registrar lists MUST
// filter registrarId==uid, or the whole query is denied.
// CREATE: only a signed-in teacher, only for their own id, and the doc
// MUST start status=='pending' — teachers can't self-approve.
// UPDATE: the registrar may edit their school but NOT flip its status;
// only an admin can change status (approve/reject). Admins may
// also edit anything.
// DELETE: registrar or admin.
match /schools/{schoolId} {
allow get: if true;
allow list: if resource.data.status == 'approved'
|| (isSignedIn() && resource.data.registrarId == request.auth.uid)
|| isAdmin();
allow create: if isSignedIn()
&& request.resource.data.registrarId == request.auth.uid
&& request.resource.data.status == 'pending'
&& exists(/databases/$(database)/documents/users/$(request.auth.uid))
&& get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == 'teacher';
allow update: if isAdmin()
|| (isSignedIn()
&& resource.data.registrarId == request.auth.uid
&& request.resource.data.status == resource.data.status);
allow delete: if isAdmin()
|| (isSignedIn() && resource.data.registrarId == request.auth.uid);
}
// --- AI Feedback Cache ---
// Written exclusively by Cloud Functions (admin SDK bypasses these rules).
// Clients have no direct access — feedback is always proxied through the function.
match /feedbackCache/{cacheKey} {
allow read, write: if false;
}
// --- Global Deny Fallback ---
match /{document=**} {
allow read, write: if false;
}
}
}