Chapter 12 · Security Rules Foundations: Authentication Context, Match Paths, Reads/Writes, and Validation
Role / Ownership Models, Public / Private Documents, Nested Resources, and Deny-by-Default Structure
Compose AtlasMart ownership, roles, public/private documents and nested-resource authorization without accidental overlapping grants.
Learning outcomes
Design role and ownership rules without trusting client-written role/owner fields.
Separate public-readable projection data from private per-user/per-tenant data and identify field-level exposure limits.
Secure nested resources explicitly and understand why parent match blocks do not inherit to subcollections.
Compose a deny-by-default ruleset whose overlapping match statements can be reviewed for accidental grants.
Use the Emulator Suite, a Firebase demo project, or an isolated test project for destructive, security-sensitive, billing-sensitive, migration, backup/restore, or write-heavy exercises unless the lesson explicitly marks managed verification as required. Treat shown output as expected evidence unless it is explicitly identified as captured output, and re-check current Firebase/Google Cloud edition, mode, quota, pricing, and security documentation before production execution.
AtlasMart continues the same mandatory environment used in
Chapters 01–11: project ID
demo-atlasmart-firestore, Standard edition /
Native mode / (default) database, Firestore
emulator 127.0.0.1:8080, Authentication emulator
127.0.0.1:9099, Emulator UI
127.0.0.1:4000, Firebase CLI
15.30.0, Firebase JavaScript SDK
12.19.0, Firebase Admin Node SDK
14.4.0 with
@google-cloud/firestore 9.1.0,
@firebase/rules-unit-testing 5.0.2, and Node.js
22+. Mandatory work is local and no-cost. The canonical lab
uses Standard Native Core operations; Enterprise Native
Core/Pipeline differences are labeled explicitly rather than
silently mixed into Standard semantics.
The Emulator Suite can prove deterministic allow/deny behavior for the rules source loaded into that emulator, mock Firebase Authentication claims, query/rule compatibility, field validation, wildcard matching and many access-call cases. It does not prove production IAM, App Check enforcement, production billing, latency, index deployment state, abuse resistance, or every backend/runtime difference. Mobile/web client requests are evaluated by Security Rules. Admin/server client libraries bypass Security Rules and are authorized by IAM; a passing client-rules test therefore says nothing about privileged server authorization.
1. The AtlasMart problem: public catalog, private profile, shared operational data
AtlasMart has different visibility classes: products are public,
profiles are private to the user, reviews may be public, orders
are private to customer/support staff, and deletion/audit
records are server-only. Forcing all of them through one generic
isSignedIn() rule either overexposes data or blocks
legitimate public access. Security architecture starts by
classifying resources and assigning a source of authority for
owner/role decisions.
| Resource | Client read | Client write | Authority |
|---|---|---|---|
| products/{id} | Public published product projection | None | Trusted catalog service |
| profiles/{uid} | Owner | Owner on display fields | Auth UID + immutable tenant/uid |
| orders/{id} | Customer or tenant staff | Client may create DRAFT only; business transitions server-side | Auth UID/claims + stored customer/tenant fields |
| products/{id}/reviews/{id} | Public if visibility=public | Author-owned constrained content | Auth UID + immutable author/product IDs |
| deletionAudits/{id} | None | None | Trusted backend via IAM |
2. Ownership must bind stored/proposed data to authenticated identity
A client-supplied ownerUid is not trustworthy
merely because it exists. On create, require it to equal
request.auth.uid. On update, require it to remain
equal to the existing value. When the document path itself
contains the UID, path ownership can be even simpler.
match /profiles/{uid} { allow get: if request.auth != null && request.auth.uid == uid;}match /reviews/{reviewId} { allow create: if request.auth != null && request.resource.data.authorUid == request.auth.uid; allow update, delete: if request.auth != null && resource.data.authorUid == request.auth.uid && request.resource.data.authorUid == resource.data.authorUid;}
3. Roles: claims vs role documents
Stable coarse roles can be delivered in Firebase Authentication
custom claims and read via request.auth.token. More
dynamic membership can live in Firestore and be checked with
get()/exists(), accepting the extra rules-read
dependency. Neither choice is universally superior: claims
require a secure issuance/revocation lifecycle and token
refresh; Firestore membership is queryable/centralized but
consumes rule-access calls and reads. Never let the client grant
itself the authoritative role.
function isStaff() { return request.auth != null && request.auth.token.tenantId == resource.data.tenantId && request.auth.token.role in ["support", "admin"];}
Chapter 13 expands the threat model around custom claims, App Check and CI regression tests.
4. Public/private fields: Rules authorize documents, not redacted field views
If a product document contains both public description fields and a secret supplier contract field, allowing the document read exposes the whole document to the client. Security Rules do not return a redacted subset. Split data with materially different confidentiality into separate documents/collections and grant access separately.
Once the product document is readable, the client receives all
fields. Move confidential supplier/finance fields to a
server-only document such as
productPrivate/{productId} and deny all client
access.
match /products/{productId} { allow get, list: if resource.data.published == true; allow write: if false;}match /productPrivate/{productId} { allow read, write: if false;}
5. Nested resources require explicit rules
A rule on /products/{productId} does not inherit
into /products/{productId}/reviews/{reviewId}.
Either nest an explicit match or use a v2 collection-group
pattern when you intentionally want the same rule across
same-named subcollections. Keep review tenant/author/product
identifiers on each review so the rule can evaluate the document
without depending on an unbounded ancestor lookup chain.
match /products/{productId} { allow get, list: if resource.data.published == true; match /reviews/{reviewId} { allow get, list: if resource.data.visibility == "public"; allow create: if request.auth != null && request.resource.data.productId == productId && request.resource.data.authorUid == request.auth.uid; }}
6. Deny-by-default is about controlling grants
Firestore does not need an explicit deny rule for unmatched paths, but an explicit final false matcher communicates intent. The critical review task is to enumerate every true grant. Overlapping match statements combine permissively: a broad recursive allow can defeat your carefully scoped rule. Treat broad wildcard additions as security-sensitive architecture changes.
match /orders/{orderId} { allow read: if false; // Looks restrictive...}match /{document=**} { allow read: if request.auth != null; // ...but this matching allow grants the read.}
7. Enterprise Pipeline and public/private authorization
Enterprise Native Pipeline operations are protected by Security
Rules too, but the Rules engine can prove only supported
comparison/logical filters against stored data, and some Core
query properties such as request.query.limit/orderBy
are not supported for Pipeline rule checks. Place
security-constraining where stages before stages
that modify/derive fields, because Rules operate on stored data.
Do not assume a Standard Core rules/query contract transfers
unchanged to a Pipeline.
8. Reproducible AtlasMart lab
{ "name": "atlasmart-firestore-ch12", "private": true, "type": "module", "engines": { "node": ">=22" }, "dependencies": { "firebase": "12.19.0", "firebase-admin": "14.4.0" }, "devDependencies": { "firebase-tools": "15.30.0", "@firebase/rules-unit-testing": "5.0.2" }}
{ "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" }, "emulators": { "firestore": { "port": 8080 }, "auth": { "port": 9099 }, "ui": { "enabled": true, "port": 4000 } }}
mkdir atlasmart-firestore-ch12 && cd atlasmart-firestore-ch12npm init -ynpm install firebase@12.19.0 firebase-admin@14.4.0npm install --save-dev firebase-tools@15.30.0 @firebase/rules-unit-testing@5.0.2# Save firebase.json, firestore.rules and firestore.indexes.json from the lesson.npx firebase-tools@15.30.0 emulators:start --project demo-atlasmart-firestore --only firestore,auth
rules_version = '2';service cloud.firestore { match /databases/{database}/documents { function signedIn() { return request.auth != null; } function sameTenant(tenantId) { return signedIn() && request.auth.token.tenantId == tenantId; } function isStaff() { return signedIn() && request.auth.token.role in ["support", "admin"]; } function profileCreateShape() { return request.resource.data.keys().hasAll(["uid", "tenantId", "displayName", "schemaVersion"]) && request.resource.data.keys().hasOnly(["uid", "tenantId", "displayName", "locale", "schemaVersion"]) && request.resource.data.uid == request.auth.uid && request.resource.data.tenantId == request.auth.token.tenantId && request.resource.data.schemaVersion == 4; } match /profiles/{uid} { allow get: if signedIn() && request.auth.uid == uid; allow list: if false; allow create: if signedIn() && request.auth.uid == uid && profileCreateShape(); allow update: if signedIn() && request.auth.uid == uid && request.resource.data.uid == resource.data.uid && request.resource.data.tenantId == resource.data.tenantId && request.resource.data.schemaVersion == resource.data.schemaVersion && request.resource.data.diff(resource.data).affectedKeys().hasOnly(["displayName", "locale"]); allow delete: if false; } match /orders/{orderId} { allow get, list: if signedIn() && resource.data.tenantId == request.auth.token.tenantId && (resource.data.customerId == request.auth.uid || isStaff()); allow create: if signedIn() && request.resource.data.customerId == request.auth.uid && request.resource.data.tenantId == request.auth.token.tenantId && request.resource.data.status == "DRAFT" && request.resource.data.schemaVersion == 4; allow update, delete: if false; } match /products/{productId}/reviews/{reviewId} { allow get, list: if resource.data.visibility == "public"; allow create: if signedIn() && request.resource.data.authorUid == request.auth.uid && request.resource.data.productId == productId && request.resource.data.tenantId == request.auth.token.tenantId && request.resource.data.rating is int && request.resource.data.rating >= 1 && request.resource.data.rating <= 5 && request.resource.data.schemaVersion == 4; allow update: if signedIn() && resource.data.authorUid == request.auth.uid && request.resource.data.authorUid == resource.data.authorUid && request.resource.data.productId == resource.data.productId && request.resource.data.tenantId == resource.data.tenantId && request.resource.data.diff(resource.data).affectedKeys().hasOnly(["rating", "text", "visibility"]); allow delete: if signedIn() && resource.data.authorUid == request.auth.uid; } match /{document=**} { allow read, write: if false; } }}
import fs from "node:fs";import { initializeTestEnvironment, assertSucceeds, assertFails} from "@firebase/rules-unit-testing";import { doc, getDoc, setDoc, updateDoc, deleteDoc, collection, query, where, getDocs} from "firebase/firestore";const testEnv = await initializeTestEnvironment({ projectId: "demo-atlasmart-firestore", firestore: { host: "127.0.0.1", port: 8080, rules: fs.readFileSync("firestore.rules", "utf8") }});await testEnv.clearFirestore();await testEnv.withSecurityRulesDisabled(async context => { const db = context.firestore(); await setDoc(doc(db, "profiles/u-1001"), { uid: "u-1001", tenantId: "t-acme", displayName: "Ava", locale: "en", schemaVersion: 4 }); await setDoc(doc(db, "profiles/u-2001"), { uid: "u-2001", tenantId: "t-acme", displayName: "Noah", locale: "en", schemaVersion: 4 }); await setDoc(doc(db, "orders/o-1001"), { tenantId: "t-acme", customerId: "u-1001", status: "PAID", createdAt: new Date("2026-09-16T12:00:00Z"), schemaVersion: 4 }); await setDoc(doc(db, "orders/o-2001"), { tenantId: "t-acme", customerId: "u-2001", status: "PAID", createdAt: new Date("2026-09-16T12:05:00Z"), schemaVersion: 4 }); await setDoc(doc(db, "products/p-1001/reviews/r-001"), { tenantId: "t-acme", productId: "p-1001", authorUid: "u-1001", rating: 5, text: "Works well", visibility: "public", createdAt: new Date("2026-09-16T12:10:00Z"), schemaVersion: 4 });});const alice = testEnv.authenticatedContext("u-1001", { tenantId: "t-acme", role: "customer" }).firestore();const staff = testEnv.authenticatedContext("staff-1", { tenantId: "t-acme", role: "support" }).firestore();const anon = testEnv.unauthenticatedContext().firestore();
await assertSucceeds(getDoc(doc(alice, "profiles/u-1001")));await assertFails(getDoc(doc(alice, "profiles/u-2001")));await assertSucceeds(getDoc(doc(staff, "orders/o-1001")));await assertSucceeds(getDoc(doc(anon, "products/p-1001/reviews/r-001")));await assertFails(setDoc(doc(alice, "products/p-1001/reviews/r-forged"), { tenantId: "t-acme", productId: "p-1001", authorUid: "u-2001", rating: 5, text: "forged owner", visibility: "public", schemaVersion: 4}));
Add a server-only productPrivate/p-1001 fixture
under disabled rules and assert every client identity is denied.
Then add a deliberately broad recursive read grant, observe a
private read succeed unexpectedly, and remove the grant. That
reversible failure is the chapter’s overlap test.
Production judgment
Authorization data should have an explicit authority and lifecycle. Public/private boundaries often need separate documents. Ownership fields must be bound to Auth identity, roles must not be self-issued, nested resources need explicit matching, and every broad grant should be reviewed for overlap. Rules do not replace rate limits, App Check, server IAM, logging or incident response; they are one enforcement layer.
Lesson 5 turns the chapter into an automated regression suite that runs before client query or rules changes ship.
Knowledge check
- Can a client-written role field be trusted for authorization?
- Can Rules expose only selected fields of an allowed document?
- Do product rules automatically protect product review subcollections?
- What is the danger of overlapping match statements?
- Do Enterprise Pipeline queries use exactly the same proveable query properties as Core queries?
Review the answers
1. Not unless a trusted process controls that field; clients must not be able to self-grant authoritative roles.
2. No. A readable document is returned as a document; split confidential fields into a separately protected document.
3. No. Nested/subcollection documents need explicit matching rules.
4. Any matching true allow grants the request, so a broad allow can defeat a narrow restriction.
5. No. Pipeline Rules support a constrained filter/properties model and require Pipeline-specific review.
Summary
AtlasMart’s rules now express a resource classification: public projections, owner-private data, role-governed operational data and server-only records. Each grant is explicit and testable.
Authoritative references
- Get started with Cloud Firestore Security Rules
- Structuring Cloud Firestore Security Rules
- Writing conditions for Cloud Firestore Security Rules
- Securely query data
- Control access to specific fields
- Test your Cloud Firestore Security Rules
- Build unit tests for Firebase Security Rules
- @firebase/rules-unit-testing reference
- Secure data access for users and groups
- Firestore SDKs and client libraries
- Enterprise security overview
- Security Rules for Pipeline operations
- Enterprise Native Core and Pipeline operations overview
- Firebase App Check
- Firestore IAM