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

FileHoldsOwned by
firestore.rulesThe Firestore rules of Arc CMSCore
storage.rulesThe Storage rules of Arc CMSCore
firestore.indexes.jsonThe Firestore indexes of Arc CMSCore
firestore.app.rules, storage.app.rules, firestore.app.indexes.jsonAn app's own rules and indexes. Optional: core ships noneThe app
.arc-build/The combined files Firebase deploys. Generated, not committedscripts/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.

WhoHow the rules know
Anyone, signed in or notNo check. Published pages and the browser app read published content without signing in
A signed-in personrequest.auth != null
The owner of a users recordThe 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 adminThe arccms_role claim is admin
Only Cloud FunctionsEvery 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

KindReadWriteExamples
Published contentAnyoneStaffContentTypes, arc_{slug}, Tags_{slug}. The translations of published content are read by anyone and written by Cloud Functions only
Public site dataAnyoneAdminAuthors, Products, Waitlists, the public Settings documents (below)
DraftsStaffStaffarc_{slug}_drafts and its translations
Media library detailsStaffStaffmedia, mediaFiles: titles, alt text and staff notes. Pages use the files' URLs, never these documents
Admin dataAdminAdminEvery other Settings document, EmailTemplate, BroadcastEmails, EmailLogs, Lists, ContactTags, DripCampaigns, AnalyticsDashboards
Records the server keepsAdminCloud Functions onlyContacts, Suppression, AppEvents, Announcements, DripEnrollments, WebhookEvents, SmsLogs, PwaStats, ErasureLog, account_transfers
A person's own recordsThe owner or an adminCloud Functions onlyTransactions, CreditLedger. Notifications: the owner may only mark one read
Server onlyNobodyCloud 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
QueuesNobodyStaff_publish_queue, _search_queue (only its own fields)
Checked writes from visitorsAdminCreated by the form or button, with the fields checked; verification and the signup count by Cloud Functions onlyForm sign-ups in Waitlists/{id}/users; Feedback from a signed-in person while the button is on
Messages from visitorsAdminCreated by Cloud Functions only; an admin may change the status (new, done, spam) and deleteContactMessages, 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

Storage

PathReadWrite
Everything not listed below (the media library's images, imports, exports)AnyoneStaff
mediaFiles/..., at the bucket root or under the upload folderAnyoneStaff, 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 folderThe owner (arccms_uid) or an admin. Never publicStaff, and whatever the app's storage.app.rules allows the owner
users/{userDocId}/feedback/{feedbackId}/...The owner or an adminThe 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}/...AnyoneThat 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.

FunctionTrue whenStable for apps
isSignedIn()Someone is signed inYes
ownsUserRecord(userDocId)The signed-in person's arccms_uid claim is that record id. No claim means noYes
isAdmin()The arccms_role claim is adminYes
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 itYes
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

  1. Edit firestore.rules or storage.rules in Arc CMS itself (an app edits its own *.app.rules file instead).

  2. Add a test to tests/rules/firestore.rules.spec.ts or tests/rules/storage.rules.spec.ts that shows the new rule allows what it should and refuses what it should not, for each kind of caller.

  3. Run the rules tests and the whole suite:

    npm run test:rules
    npm run test
  4. 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.