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.rulesfirestore.app.rules.arc-build/firestore.rules
storage.rulesstorage.app.rules.arc-build/storage.rules
firestore.indexes.jsonfirestore.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:

HelperTrue 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

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

  1. Create a spec in tests/rules/custom/, such as tests/rules/custom/children.spec.ts. Load the combined rules, as the core specs in tests/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')));
    });
  2. Run every rules spec, core and yours, against the Firestore and Storage emulators:

    npm run test:rules

    It 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.