Security rules
The security rules decide what a browser may read and write in Firestore and Storage. Cloud Functions use the Admin SDK and are not bound by them. This page shows how the rules are organized, the access model, and how to change a rule without opening a hole.
An app built on Arc CMS writes its own rules in its own files: see Set security rules and indexes. Roles and the claims the rules read are explained in Roles and claims.
Where the rules live
| File | Holds | Owned by |
|---|---|---|
firestore.rules | The Firestore rules of Arc CMS | Core |
storage.rules | The Storage rules of Arc CMS | Core |
firestore.indexes.json | The Firestore indexes of Arc CMS | Core |
firestore.app.rules, storage.app.rules, firestore.app.indexes.json | An app's own rules and indexes. Optional: core ships none | The app |
.arc-build/ | The combined files Firebase deploys. Generated, not committed | scripts/arc-rules-build.mjs |
Firebase takes one rules file per database and one per bucket, so scripts/arc-rules-build.mjs combines the core and app files into .arc-build/. It puts each app rules file where the core file has the line // @arc-app-rules, inside the same scope, so app rules can call the core helper functions. It merges the app's indexes into the core ones and removes duplicates; a field override that both files set differently stops the build.
firebase.json runs the combine step before every Firestore and Storage deploy. To run it by hand, for example to read the combined result:
npm run rules:build
Keep the // @arc-app-rules line in both core rules files, on a line of its own. Without it the combine step stops, and no rules can be deployed.
The access model
Every rule answers one question: who is asking? The answers come from the ID token.
| Who | How the rules know |
|---|---|
| Anyone, signed in or not | No check. Published pages and the browser app read published content without signing in |
| A signed-in person | request.auth != null |
The owner of a users record | The arccms_uid claim equals the record id, or the record's uid field equals the Auth uid |
| Staff (an admin or an editor) | The arccms_role claim is admin or editor |
| An admin | The arccms_role claim is admin |
| Only Cloud Functions | Every client is refused (allow read, write: if false) |
The onUserRoleChange function copies the role field of a users record into the arccms_role claim, and only admins or the Admin SDK may change that field, so nobody can make themselves staff.
Firestore, by kind of data
| Kind | Read | Write | Examples |
|---|---|---|---|
| Published content | Anyone | Staff | ContentTypes, arc_{slug}, Tags_{slug}. The translations of published content are read by anyone and written by Cloud Functions only |
| Public site data | Anyone | Admin | Authors, Products, Waitlists, the public Settings documents (below) |
| Drafts | Staff | Staff | arc_{slug}_drafts and its translations |
| Media library details | Staff | Staff | media, mediaFiles: titles, alt text and staff notes. Pages use the files' URLs, never these documents |
| Admin data | Admin | Admin | Every other Settings document, EmailTemplate, BroadcastEmails, EmailLogs, Lists, ContactTags, DripCampaigns, AnalyticsDashboards |
| Records the server keeps | Admin | Cloud Functions only | Contacts, Suppression, AppEvents, Announcements, DripEnrollments, WebhookEvents, SmsLogs, PwaStats, ErasureLog, account_transfers |
| A person's own records | The owner or an admin | Cloud Functions only | Transactions, CreditLedger. Notifications: the owner may only mark one read |
| Server only | Nobody | Cloud Functions only | _system, SearchIndex, signup_otps, phone_otps, phone_index, auth_pins, app_pins, app_phone_pin_tries, _rate_limits, _claim_locks, _broadcast_continue, _email_counters |
| Queues | Nobody | Staff | _publish_queue, _search_queue (only its own fields) |
| Checked writes from visitors | Admin | Created by the form or button, with the fields checked; verification and the signup count by Cloud Functions only | Form sign-ups in Waitlists/{id}/users; Feedback from a signed-in person while the button is on |
| Messages from visitors | Admin | Created by Cloud Functions only; an admin may change the status (new, done, spam) and delete | ContactMessages, sent with the contact form (see Contact form) |
The Settings documents anyone may read are email_status, site-usage, misc, cache, global-message, users, localization, onboarding_status, about and feedback. Every Settings write is admin only. analytics_status is readable by any signed-in person, search_status by admins; functions write both. Every collection with its fields is listed in Data model.
Users
- Read: your own record, or any as an admin. A browser query on
usersmust filter onuid == request.auth.uid, or the rules refuse the whole query. - Create: admins only. Every sign-up creates its record on the server, which checks the email, sets the site's default role and refuses when sign-ups are closed.
- Update: your own record, but never
role,uid,isActive,status,authOwner,by,selfService,email,phone,phoneVerified, the detach fields, the premium and credit fields, andemailVerifiedonly tofalse. A locked app account (by: 'app'withoutselfService: true, see App accounts) also never changes itsname,photooremailVerified. Admins may update any record. No record may hold apassword. - Delete: admins only.
Storage
| Path | Read | Write |
|---|---|---|
| Everything not listed below (the media library's images, imports, exports) | Anyone | Staff |
mediaFiles/..., at the bucket root or under the upload folder | Anyone | Staff, only PDF, Word, Excel, PowerPoint, CSV and text, each extension with its own content type, up to 20 MB (isLibraryFile) |
users/{userDocId}/..., at the bucket root or under the upload folder | The owner (arccms_uid) or an admin. Never public | Staff, and whatever the app's storage.app.rules allows the owner |
users/{userDocId}/feedback/{feedbackId}/... | The owner or an admin | The owner may create screenshot.jpg (JPEG, under 5 MB) or a voice note (voice.mp4, .webm or .ogg, under 10 MB), never overwrite |
avatars/{uid}/... | Anyone | That signed-in person: images under 5 MB |
Any folder named users in the first two levels of a path is never public.
The helper functions
The top of firestore.rules defines these, and storage.rules the same names with the same meaning. App rules may call the five marked stable; keep their names and meaning when you change core.
| Function | True when | Stable for apps |
|---|---|---|
isSignedIn() | Someone is signed in | Yes |
ownsUserRecord(userDocId) | The signed-in person's arccms_uid claim is that record id. No claim means no | Yes |
isAdmin() | The arccms_role claim is admin | Yes |
isEditor() | An admin, or the arccms_role claim is editor (content staff) | Yes |
signedInByApp() | The session came from an app's own sign-in token (sign_in_provider is custom). Core rules do not use it | Yes |
isAuthenticated() | The same as isSignedIn(), used by core | |
arcRole() | Returns the arccms_role claim, or an empty string |
Two more live with the users rules: isOwnUserDoc() (the record's uid field is the caller) and hasNoPassword(data). Storage adds isSmallImage() (an image under 5 MB).
Never check a plain role claim. An app sharing the sign-in accounts may give its own admins role: 'admin', and they are not Arc CMS admins. Use isAdmin() or isEditor(), which read arccms_role. A test in the default suite fails if a plain role check comes back.
Change a rule safely
Edit
firestore.rulesorstorage.rulesin Arc CMS itself (an app edits its own*.app.rulesfile instead).Add a test to
tests/rules/firestore.rules.spec.tsortests/rules/storage.rules.spec.tsthat shows the new rule allows what it should and refuses what it should not, for each kind of caller.-
Run the rules tests and the whole suite:
npm run test:rulesnpm run test Deploy the code the rule depends on first, and the rules last (see When the order matters):
npm run deploy -- --only firestore:rules --project default
Rules only ever add access: if any allow that matches a request is true, the request is allowed. A narrower rule with if false does not take away what a broader one grants. To close something, change the rule that grants it.
The rule for published content is one catch-all, match /{collection}/{docId}, that tests the collection name: Firestore has no prefix matching. Any collection named arc_... (and not ending in _drafts) or Tags_... is therefore public to read. Never give private data a name like that.
The rules tests
npm run test:rules combines the rules, starts the Firestore and Storage emulators with the Firebase CLI under a demo project, and runs every spec in tests/rules/ against the combined files with @firebase/rules-unit-testing. It needs the Firebase CLI and Java, which the emulators run on. The files run one at a time (vitest.rules.config.ts).
They are not part of npm run test, because that needs no emulator. The default suite has its own guard instead: functions/src/__tests__/securityRulesRoles.spec.ts reads the rules files and fails if a known hole comes back, such as a signed-in person writing their own role, content writable by any signed-in person, or a path writable by everyone signed in. More on the test setup is in Testing.