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.

Intermediate150–180 minutesOwnership · roles · least privilegeFirebase JS 12.19.0 · Admin 14.4.0 · CLI 15.30.0 · Rules tests 5.0.2Last reviewed: September 2026

Learning outcomes

01

Design role and ownership rules without trusting client-written role/owner fields.

02

Separate public-readable projection data from private per-user/per-tenant data and identify field-level exposure limits.

03

Secure nested resources explicitly and understand why parent match blocks do not inherit to subcollections.

04

Compose a deny-by-default ruleset whose overlapping match statements can be reviewed for accidental grants.

Execution and safety note

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.

Chapter 12 reproducibility baseline · reviewed 16 September 2026

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.

Security evidence boundary

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.

path and data ownership patterns
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.

claim-based staff helper
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.

Wrong approach: store secretCost beside public product fields and “hide it in the UI”

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.

split public/private documents
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.

explicit nested rule
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.

dangerous overlap
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

package.json
{  "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"  }}
firebase.json
{  "firestore": {    "rules": "firestore.rules",    "indexes": "firestore.indexes.json"  },  "emulators": {    "firestore": { "port": 8080 },    "auth": { "port": 9099 },    "ui": { "enabled": true, "port": 4000 }  }}
local setup
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
firestore.rules · deny by default with explicit AtlasMart grants
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; }  }}
rules test setup and deterministic seed
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();
role/ownership/public tests
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

  1. Can a client-written role field be trusted for authorization?
  2. Can Rules expose only selected fields of an allowed document?
  3. Do product rules automatically protect product review subcollections?
  4. What is the danger of overlapping match statements?
  5. 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

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.