Set security rules and indexes
Your app's data shares the Firestore database and Storage bucket with Arc CMS, and Firebase takes one rules file for each. So you keep your rules and indexes in files of your own, and every deploy combines them with the core files.
The files
| Core (never edited in an app) | Yours (create when needed) | Combined into |
|---|---|---|
firestore.rules | firestore.app.rules | .arc-build/firestore.rules |
storage.rules | storage.app.rules | .arc-build/storage.rules |
firestore.indexes.json | firestore.app.indexes.json | .arc-build/firestore.indexes.json |
scripts/arc-rules-build.mjs writes the combined files, and firebase.json deploys those. It runs by itself before every Firestore and Storage deploy (a Firebase predeploy), so a plain deploy always ships your rules. .arc-build/ is ignored by git. To build the files by hand and look at them:
npm run rules:build
Write Firestore rules
Create firestore.app.rules at the root of the repository. It holds match blocks, and functions of your own if you need them, with no rules_version line and no service or match /databases/... wrapper:
match /users/{userDocId}/children/{childId} {
allow read, write: if ownsUserRecord(userDocId);
allow read: if isAdmin();
match /progress/{lessonId} {
allow read, write: if ownsUserRecord(userDocId);
}
}
match /Lessons/{lessonId} {
allow read: if isSignedIn();
allow write: if isAdmin();
}
The build puts your file where the core file has the line // @arc-app-rules, inside match /databases/{database}/documents, so your rules can call the core helpers.
Write Storage rules
Create storage.app.rules the same way. It goes inside match /b/{bucket}/o. The core rules let the owner and admins read the per-user folder, users/{userDocId}/, but grant no writes there; your app grants them with its own limits:
match /users/{userDocId}/recordings/{fileName} {
allow write: if ownsUserRecord(userDocId)
&& (request.resource == null
|| (request.resource.size < 10 * 1024 * 1024
&& request.resource.contentType.matches('audio/.*')));
}
Every other path in the bucket is public for reading, except folders named users in the first two levels. Put private files under users/{userDocId}/.
An install that keeps its uploads in a folder (an upload prefix set with npm run arc:configure) stores the per-user folders at {prefix}/users/{userDocId}/. A rule for /users/... alone never matches there. Add the same rule for /{prefix}/users/{userDocId}/..., as the core rules do.
The helpers you can call
Both core files define these for app rules, with the same names and meaning:
| Helper | True when |
|---|---|
isSignedIn() | Anyone is signed in |
isAdmin() | The person is an Arc CMS admin: the arccms_role claim is admin. A plain role claim does not count |
isEditor() | An admin, or the arccms_role claim is editor |
ownsUserRecord(userDocId) | The person owns users/{userDocId}: the arccms_uid claim equals it. No document is read |
signedInByApp() | The session came from your app's own sign-in token (signInWithCustomToken), not Google, a password or a phone code. Add it to a rule that trusts your own claims on app accounts |
The claims are explained in Roles and claims, and where per-person data belongs in Follow the account contract.
What an app rule can and cannot do
- It can only add access. Firebase allows a request when any matching rule allows it. Your rule can grant more; it can never take away what a core rule grants.
- Keep it to your own paths. A rule on a core collection can only widen access to it. If core's access is wrong for your app, that is a change to Arc CMS.
- Name your functions differently from core's. Besides the helpers above, the core Firestore file defines functions such as
isAuthenticated,isOwnUserDoc,hasNoPasswordandarcRole. Prefix your own, for exampleappIsTeacher(). - Choose collection names core does not use. Core's rules make every collection named
arc_...orTags_...readable by anyone and writable by editors, so a collection of yours with such a name is public whatever your rule says.app_pinsandapp_phone_pin_triesare core's too, closed to every browser: they hold your app's PINs and its gates' counts of wrong phone PINs (see PINs for your app). - No wrapper. A
rules_versionline or aserviceblock in your file breaks the combined file.
Firestore denies every path no rule matches, so a collection of yours without a rule is closed to the browser. Cloud Functions are not bound by rules.
Add indexes
Create firestore.app.indexes.json with the same shape as firestore.indexes.json:
{
"indexes": [
{
"collectionGroup": "progress",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "done", "order": "ASCENDING" },
{ "fieldPath": "updatedAt", "order": "DESCENDING" }
]
}
],
"fieldOverrides": []
}
The build merges it with the core indexes and drops duplicates. A field override that both files define differently stops the build with a message: keep it in one file.
npm run export-indexes copies the live indexes into the core file, firestore.indexes.json, leaving out those listed in your app file. An index you created in the Firebase console and did not add to firestore.app.indexes.json first lands in the core file, and npm run check:core reports it. Add app indexes to your own file by hand.
Deploy
Deploy rules and indexes like any other part; the combined files are built first. See Deploy and Security rules.
Test your rules
-
Create a spec in
tests/rules/custom/, such astests/rules/custom/children.spec.ts. Load the combined rules, as the core specs intests/rules/do, so your rules are tested together with the core ones:import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { afterAll, beforeAll, it } from 'vitest'; import { assertFails, assertSucceeds, initializeTestEnvironment, RulesTestEnvironment } from '@firebase/rules-unit-testing'; import { doc, getDoc } from 'firebase/firestore'; let env: RulesTestEnvironment; beforeAll(async () => { env = await initializeTestEnvironment({ projectId: 'demo-arccms-rules', firestore: { rules: readFileSync(resolve(__dirname, '../../../.arc-build/firestore.rules'), 'utf8') }, }); }); afterAll(async () => { await env?.cleanup(); }); it('lets a parent read their own child', async () => { const parent = env.authenticatedContext('parent-uid', { arccms_uid: 'parent-doc' }).firestore(); await assertSucceeds(getDoc(doc(parent, 'users/parent-doc/children/c1'))); }); it('refuses another parent', async () => { const other = env.authenticatedContext('other-uid', { arccms_uid: 'other-doc' }).firestore(); await assertFails(getDoc(doc(other, 'users/parent-doc/children/c1'))); }); -
Run every rules spec, core and yours, against the Firestore and Storage emulators:
npm run test:rulesIt builds the combined files first. It needs Java and the Firebase CLI, and it is not part of
npm run test.
Run it whenever you change a rules file. See Testing.