rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { // =============================================================== // Helper Functions // =============================================================== function isAuthenticated() { return request.auth != null; } function isOwner(userId) { return isAuthenticated() && request.auth.uid == userId; } function isDocOwner() { return isAuthenticated() && request.auth.uid == resource.data.authorUid; } function uidUnchanged() { return !('authorUid' in request.resource.data) || request.resource.data.authorUid == request.auth.uid; } function uidNotModified() { return !('authorUid' in request.resource.data) || request.resource.data.authorUid == resource.data.authorUid; } function hasRequiredFields(fields) { return request.resource.data.keys().hasAll(fields); } function isValidUrl(url) { return url is string && (url.matches("^https://.*") || url.matches("^http://.*")); } function isValidNewsItem(item) { return item is map && item.keys().hasAll(['title', 'url', 'summary']) && item.title is string && item.title.size() > 0 && item.title.size() < 500 && item.url is string && item.url.size() < 1000 && item.summary is string && item.summary.size() > 0 && item.summary.size() < 2000; } function isValidNewsArray(arr) { return arr is list && arr.size() <= 50; // Max 50 items per section } function isValidSynthesis(data) { return hasRequiredFields(['week', 'createdAt', 'authorUid']) && data.week is string && data.week.size() > 0 && data.week.size() < 20 && data.createdAt is timestamp && data.authorUid is string && data.authorUid.size() > 0 && (!('majorAnnouncements' in data) || isValidNewsArray(data.majorAnnouncements)) && (!('financialSector' in data) || isValidNewsArray(data.financialSector)) && (!('otherEnterprises' in data) || isValidNewsArray(data.otherEnterprises)) && (!('publicSector' in data) || isValidNewsArray(data.publicSector)) && (!('generalPublic' in data) || isValidNewsArray(data.generalPublic)) && (!('sections' in data) || data.sections is list); } function isValidSettings(data) { return hasRequiredFields(['theme', 'maxAgeDays', 'categories', 'maxItemsPerCategory']) && data.theme is string && data.theme.size() > 0 && data.theme.size() < 200 && data.maxAgeDays is number && data.maxAgeDays > 0 && data.maxAgeDays <= 365 && data.categories is list && data.categories.size() > 0 && data.categories.size() <= 20 && data.maxItemsPerCategory is number && data.maxItemsPerCategory > 0 && data.maxItemsPerCategory <= 50; } function isValidSource(data) { return hasRequiredFields(['title', 'url', 'authorUid', 'createdAt']) && data.title is string && data.title.size() > 0 && data.title.size() < 200 && data.url is string && isValidUrl(data.url) && data.url.size() < 1000 && data.authorUid is string && data.authorUid.size() > 0 && data.createdAt is timestamp; } match /syntheses/{synthesisId} { allow read: if isAuthenticated() && isDocOwner(); allow create: if isAuthenticated() && isValidSynthesis(request.resource.data) && uidUnchanged(); allow update: if isAuthenticated() && isDocOwner() && isValidSynthesis(request.resource.data) && uidNotModified() && request.resource.data.createdAt == resource.data.createdAt; allow delete: if isAuthenticated() && isDocOwner(); } match /sources/{sourceId} { allow read: if isAuthenticated() && isDocOwner(); allow create: if isAuthenticated() && isValidSource(request.resource.data) && uidUnchanged(); allow update: if isAuthenticated() && isDocOwner() && isValidSource(request.resource.data) && uidNotModified() && request.resource.data.createdAt == resource.data.createdAt; allow delete: if isAuthenticated() && isDocOwner(); } match /settings/{userId} { allow read: if isAuthenticated() && request.auth.uid == userId; allow write: if isAuthenticated() && request.auth.uid == userId && isValidSettings(request.resource.data); } } }