Skip to content
Navigation Menu
{{ message }}
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfirestore.rules
More file actions
252 lines (214 loc) · 10.6 KB
/
Copy pathfirestore.rules
File metadata and controls
252 lines (214 loc) · 10.6 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
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Contacts collection - metadata only (timestamp + newsletter opt-in flag; no message content)
match /contacts/{document} {
allow create: if false;
allow read: if isAdminUser();
allow update, delete: if false;
}
// IP-only rate limits for contact form cooldown (written by Cloud Functions only)
match /contactRateLimits/{document} {
allow read, write: if false;
}
// Per-email daily rate limits for contact form (metadata only, Cloud Functions only)
match /contactEmailRateLimits/{document} {
allow read, write: if false;
}
// Contact form email delivery failures (metadata only — Cloud Functions only)
match /contactDeliveryFailures/{document} {
allow read: if isAdminUser();
allow create, update, delete: if false;
}
// Subscribers collection - newsletter subscriptions
match /subscribers/{document} {
// Allow anyone to create (for public newsletter signup)
// Allow updates (for status changes, etc.)
// Block writes if site is in read-only mode
allow create, update: if !siteIsReadOnly();
// Only admins can read subscribers (for dashboard activity)
// No public reads - this data is private
allow read: if isAdminUser();
// Delete is handled by Cloud Functions (Admin SDK bypasses rules)
// Deny client-side deletes for security
allow delete: if false;
}
// Dashboard roles: SuperAdmin, Admin, Moderator (isAdmin must be true).
// Excludes Pending/User even if isAdmin were set incorrectly.
function hasDashboardRole() {
let userDoc = get(/databases/$(database)/documents/adminUsers/$(request.auth.uid));
return request.auth != null &&
request.auth.uid != null &&
exists(/databases/$(database)/documents/adminUsers/$(request.auth.uid)) &&
userDoc.data.isAdmin == true &&
(userDoc.data.userRole == 'SuperAdmin' ||
userDoc.data.userRole == 'Admin' ||
userDoc.data.userRole == 'Moderator' ||
!userDoc.data.keys().hasAny(['userRole']));
}
// Any dashboard role (SuperAdmin, Admin, Moderator) — same privileges as legacy isAdmin check
function isAdminUser() {
return hasDashboardRole();
}
// Full dashboard access: Super Admin or Admin (not Moderator).
// SuperAdmin has the same Firestore privileges as Admin today; may diverge later.
function isFullAdmin() {
let userDoc = get(/databases/$(database)/documents/adminUsers/$(request.auth.uid));
return request.auth != null &&
request.auth.uid != null &&
exists(/databases/$(database)/documents/adminUsers/$(request.auth.uid)) &&
userDoc.data.isAdmin == true &&
(userDoc.data.userRole == 'Admin' ||
userDoc.data.userRole == 'SuperAdmin' ||
!userDoc.data.keys().hasAny(['userRole']));
}
// Helper function to check if site is in read-only mode or nuclear lockdown
// Returns true if readOnlyMode is enabled AND user is not an admin
// OR if nuclearLockdown is enabled (blocks EVERYONE including admins)
// Handles case where document doesn't exist (fail-safe: not read-only)
function siteIsReadOnly() {
// Check if document exists first (fail-safe: if it doesn't exist, not read-only)
// Nuclear lockdown blocks EVERYONE including admins
// Read-only mode blocks non-admins
return exists(/databases/$(database)/documents/siteSettings/emergency) &&
(
// Nuclear lockdown blocks ALL writes (even admins)
get(/databases/$(database)/documents/siteSettings/emergency).data.nuclearLockdown == true ||
// Read-only mode blocks non-admins
(get(/databases/$(database)/documents/siteSettings/emergency).data.readOnlyMode == true && !isAdminUser())
);
}
// Helper function to validate content document fields
// Allows all fields used by CMS and YouTube importer (tags, type, youtubeVideoId, etc.)
// Also allows archive-specific fields (archive, originalDate, originalAuthor, archiveSource)
function isValidContentFields(data) {
let allowedFields = [
'title', 'slug', 'content', 'excerpt', 'status', 'authorId', 'authorEmail',
'createdAt', 'updatedAt', 'publishedAt', 'oldSlugs', 'tags', 'featuredImage',
'type', 'youtubeVideoId', 'youtubeUrl', 'thumbnailUrl',
'archive', 'originalDate', 'originalAuthor', 'archiveSource'
];
// Check that all keys in data are in the allowed list (fields are optional)
return data.keys().hasOnly(allowedFields);
}
// Admin users collection - for CMS authentication
match /adminUsers/{userId} {
// New sign-ups must start as Pending with no dashboard access (enforced server-side)
allow create: if request.auth != null &&
request.auth.uid == userId &&
!siteIsReadOnly() &&
request.resource.data.isAdmin == false &&
request.resource.data.userRole == 'Pending' &&
request.resource.data.email is string &&
request.resource.data.email.size() > 0;
// Own doc: email verification / last login only — cannot change isAdmin or userRole
// Full admins (SuperAdmin, Admin): user management and any field updates
allow update: if (
request.auth != null &&
request.auth.uid == userId &&
!request.resource.data.diff(resource.data).affectedKeys().hasAny(['isAdmin', 'userRole'])
) || (
isFullAdmin() && !siteIsReadOnly()
);
// CRITICAL: Allow users to read their own document (required for isAdminUser() to work)
// Allow admins to read all admin documents (for user management)
allow get: if (request.auth != null && request.auth.uid == userId) ||
isAdminUser();
// Allow admins to list all admin documents (for dashboard)
allow list: if isAdminUser();
// Allow only full admins to delete user documents (for user management)
// Moderators cannot delete users
allow delete: if isFullAdmin();
}
// Failed attempts collection - for rate limiting
match /failedAttempts/{email} {
// Allow anyone to create and update failed attempt records (for rate limiting)
// This is necessary for tracking failed login attempts even for non-authenticated users
allow create, update: if true;
// Allow reading for rate limiting checks (even without auth)
allow read: if true;
// Deny delete operations
allow delete: if false;
}
// Page views collection - stores unique daily visitor fingerprints (no direct PII)
match /pageViews/{viewId} {
// Only allow writes from backend (Cloud Functions / admin SDK)
allow read, write: if false;
}
// Aggregated statistics collection (e.g., siteStats)
match /stats/{statId} {
// Only allow reads for authenticated admins (for dashboard)
allow read: if isAdminUser();
// Writes only from backend (Cloud Functions / admin SDK)
allow write: if false;
}
// Settings collection - for app configuration (e.g., YouTube settings)
match /settings/{settingId} {
// Allow admins to read settings
allow read: if isAdminUser();
// Allow admins to create and update settings (unless read-only mode for non-admins)
allow create, update: if isAdminUser() && !siteIsReadOnly();
// Deny delete operations
allow delete: if false;
}
// Site settings collection - emergency site controls
match /siteSettings/{settingId} {
// Public welcome + emergency settings; drafts/versions live under adminSettings
allow read: if true;
// Only full admins can create/update emergency settings
// Must be full admin (not moderator) - these are critical settings
// Admins can always update (even in read-only mode, they need to turn it off)
// Note: We don't check siteIsReadOnly() here to allow admins to disable read-only mode
allow create, update: if isFullAdmin();
// Deny delete operations
allow delete: if false;
}
// Welcome page draft + version history (admin only — not public)
match /adminSettings/welcomeDraft {
allow read, create, update, delete: if isFullAdmin();
}
// Placeholder parent doc for welcome version subcollection
match /adminSettings/welcomeVersions {
allow read, create, update: if isFullAdmin();
allow delete: if false;
}
match /adminSettings/welcomeVersions/versions/{versionId} {
allow read, create, update, delete: if isFullAdmin();
}
// Content collection - articles and blog posts
match /content/{contentId} {
// READ RULES:
// - Public can read (get + list/query) ONLY published articles (status == 'published')
// - Draft/unpublished content is NOT readable by the public
// - Admins can read any content (published or draft) - this allows slug uniqueness queries
// Note: For list queries, each document in the result is checked against this rule
// So public users will only see published documents, admins see all
allow read: if resource.data.status == 'published' || isAdminUser();
// WRITE RULES (admin-only):
// Allow admins to create content with any authorId (for importing/creating content on behalf of others)
// Require authorId exists and is a non-empty string
// Block writes if site is in read-only mode (admins can still write)
allow create: if isAdminUser() &&
request.resource.data.authorId is string &&
request.resource.data.authorId.size() > 0 &&
request.resource.data.status in ['draft', 'published'] &&
isValidContentFields(request.resource.data) &&
!siteIsReadOnly();
// Allow admins to update content with validated fields (including tags and YouTube fields)
// Preserve authorId immutability - authorId cannot be changed on update
// Block writes if site is in read-only mode (admins can still write)
allow update: if isAdminUser() &&
request.resource.data.authorId == resource.data.authorId &&
request.resource.data.status in ['draft', 'published'] &&
isValidContentFields(request.resource.data) &&
!siteIsReadOnly();
// Allow admins to delete content
// Block writes if site is in read-only mode (admins can still write)
allow delete: if isAdminUser() && !siteIsReadOnly();
}
// Deny all other collections
match /{document=**} {
allow read, write: if false;
}
}
}
You can’t perform that action at this time.
