rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { // 0. Global Safety Net match /{document=**} { allow read, write: if false; } // --- Helpers --- function isSignedIn() { return request.auth != null; } function getUserId() { return request.auth.token.email.endsWith('@examforge.com') ? request.auth.token.email.split('@')[0] : request.auth.uid; } function getUserData(userId) { return get(/databases/$(database)/documents/users/$(userId)).data; } function isTeacher() { return isSignedIn() && request.auth.token.email != null && getUserData(getUserId()).role == 'teacher'; } function isValidId(id) { return id is string && id.size() <= 128 && id.matches('^[a-zA-Z0-9_\\-]+$'); } function incoming() { return request.resource.data; } function existing() { return resource.data; } // --- User Validation --- function isValidUser(data) { return data.keys().hasAll(['id', 'fullName', 'role', 'createdAt']) && data.id is string && data.id.size() <= 64 && data.fullName is string && data.fullName.size() <= 100 && data.role in ['student', 'teacher']; } match /users/{userId} { allow get: if isSignedIn() && (getUserId() == userId || isTeacher()); allow list: if isTeacher(); // Teachers can browse students for groups allow create: if isSignedIn() && getUserId() == userId && isValidUser(incoming()); allow update: if isSignedIn() && getUserId() == userId && incoming().id == existing().id && incoming().role == existing().role && incoming().diff(existing()).affectedKeys().hasOnly(['fullName', 'class', 'department', 'position', 'subject', 'examHistory', 'groupIds']); } // --- Question Validation --- function isValidQuestion(data) { return data.keys().hasAll(['id', 'text', 'options', 'correctOptionId', 'teacherId', 'createdAt']) && data.teacherId == getUserId() && data.options.size() >= 2 && data.options.size() <= 8; } match /questions/{questionId} { allow read: if isSignedIn(); allow create: if isTeacher() && isValidId(questionId) && isValidQuestion(incoming()) && incoming().createdAt == request.time; allow update: if isTeacher() && existing().teacherId == getUserId() && isValidQuestion(incoming()) && incoming().teacherId == existing().teacherId; allow delete: if isTeacher() && existing().teacherId == getUserId(); } // --- Group Validation --- function isValidGroup(data) { return data.keys().hasAll(['id', 'name', 'teacherId', 'studentIds', 'createdAt']) && data.teacherId == getUserId() && data.name is string && data.name.size() > 0; } match /groups/{groupId} { allow read: if isSignedIn() && (resource.data.teacherId == getUserId() || resource.data.studentIds.hasAny([getUserId()])); allow create: if isTeacher() && isValidId(groupId) && isValidGroup(incoming()) && incoming().createdAt == request.time; allow update: if isTeacher() && existing().teacherId == getUserId() && isValidGroup(incoming()) && incoming().teacherId == existing().teacherId; allow delete: if isTeacher() && existing().teacherId == getUserId(); } // --- Exam Validation --- function isValidExam(data) { return data.keys().hasAll(['id', 'title', 'teacherId', 'duration', 'status', 'createdAt', 'settings', 'questionIds']) && data.teacherId == getUserId() && data.duration is number && data.duration > 0; } match /exams/{examId} { allow read: if isSignedIn(); allow create: if isTeacher() && isValidId(examId) && isValidExam(incoming()) && incoming().createdAt == request.time; allow update: if isTeacher() && existing().teacherId == getUserId() && isValidExam(incoming()) && ( // Draft editing (existing().status == 'draft' && !incoming().diff(existing()).affectedKeys().hasAll(['status'])) || // Quick status switch incoming().diff(existing()).affectedKeys().hasOnly(['status']) ); allow delete: if isTeacher() && existing().teacherId == getUserId() && existing().status == 'draft'; } // --- Attempt Validation --- match /attempts/{attemptId} { allow list: if isSignedIn() && (resource.data.studentId == getUserId() || isTeacher()); allow get: if isSignedIn() && (resource.data.studentId == getUserId() || isTeacher()); allow create: if isSignedIn() && isValidId(attemptId) && incoming().studentId == getUserId() && incoming().startedAt == request.time && exists(/databases/$(database)/documents/exams/$(incoming().examId)); allow update: if isSignedIn() && existing().studentId == getUserId() && existing().status == 'started' && incoming().diff(existing()).affectedKeys().hasOnly(['answers', 'score', 'percentage', 'timeSpent', 'status', 'completedAt']) && (incoming().status != 'completed' || incoming().completedAt == request.time); } // --- Counter Validation --- match /counters/{counterId} { allow read, write: if true; } } }