// Firestore security rules — the actual tenant boundary for DRB. // // WHY THIS FILE EXISTS: drb-frontend reads Firestore directly from the // browser (see lib/use*.ts — onSnapshot(collection(db, ...))), so c2-core's // app/internal/auth.py is NOT in that read path at all. These rules are the // only thing standing between a signed-in stranger and every org's radio // traffic. Before this file existed, whatever rules were live had been // hand-set in the Firebase console: unversioned, unreviewed, unknown. See // SAAS_PLAN.md B1. // // DEPLOY IS A MANUAL, OUT-OF-BAND STEP — nothing in CI or this codebase // pushes these rules to Firebase: // firebase deploy --only firestore:rules --project // (from this directory, or point --config at infra/firestore/firebase.json // from the repo root). Do this before or immediately after the code that // starts stamping org_id ships — until these rules are live, the // console-configured rules are still what's actually enforced. // // MODEL: c2-core (firebase-admin SDK, server-side) bypasses these rules // entirely and is the sole writer for every collection below — that was // already the architecture (see CLAUDE.md "Auth — three distinct // mechanisms"). These rules therefore only need to gate READS for the // browser client, and can safely deny ALL client writes. // // Deny-by-default: the catch-all match at the bottom denies anything not // explicitly listed above it, including collections added later that // someone forgets to add a rule for. rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { function signedIn() { return request.auth != null; } // Platform-level role (admin/operator/viewer) — set by drb-c2-core // routers/users.py custom claims. Distinct from org_role (owner/member), // which is per-organization. A platform admin can read across every org // (support/debugging), mirroring internal/auth.py's require_org() // ?org_id= override for the same role. function isPlatformAdmin() { return signedIn() && (request.auth.token.role == 'admin' || request.auth.token.admin == true); } // The org_id claim is set by POST /auth/signup (or /admin/users) at // account-provisioning time. No claim => no access, by construction — // this is what backs AuthProvider's no-claim guard (SAAS_PLAN.md B3): // a user with no org_id claim can hold a valid Firebase session and // still read nothing here. function myOrgId() { return request.auth.token.org_id; } function inOrg(orgId) { return signedIn() && (myOrgId() == orgId || isPlatformAdmin()); } function docInMyOrg() { return inOrg(resource.data.org_id); } // ── Org identity ────────────────────────────────────────────────────── match /organizations/{orgId} { allow read: if inOrg(orgId); allow write: if false; // c2-core only (POST /auth/signup, routers/org.py) } match /org_members/{uid} { allow read: if signedIn() && (request.auth.uid == uid || isPlatformAdmin() || inOrg(resource.data.org_id)); allow write: if false; // c2-core only } // ── Tenant-scoped radio data — the whole point of this file ─────────── match /nodes/{nodeId} { allow read: if docInMyOrg(); allow write: if false; } match /systems/{systemId} { allow read: if docInMyOrg(); allow write: if false; } match /calls/{callId} { allow read: if docInMyOrg(); allow write: if false; } match /incidents/{incidentId} { allow read: if docInMyOrg(); allow write: if false; } match /alert_events/{alertId} { allow read: if docInMyOrg(); allow write: if false; } match /alert_rules/{ruleId} { allow read: if docInMyOrg(); allow write: if false; } // ── Never client-readable, org-scoped or not ─────────────────────────── // Secrets / credential material. Reads for these go through c2-core // REST routes (which apply their own auth), never straight to Firestore. match /node_keys/{nodeId} { allow read, write: if false; } match /enrollment_tokens/{tokenHash} { allow read, write: if false; // routers/org.py mints/lists/revokes server-side } match /org_api_keys/{keyId} { allow read, write: if false; // not implemented server-side yet (DEFERRED.md) — deny regardless } // ── Platform-admin-only collections ──────────────────────────────────── // Listed here mainly so the deny-default catch-all's intent is explicit; // these are already read/written exclusively through c2-core admin // routes (require_admin_token), never straight from the browser. match /audit_log/{entryId} { allow read, write: if false; } match /config/{docId} { allow read, write: if false; } match /bot_tokens/{tokenId} { allow read, write: if false; } match /waitlist/{entryId} { allow read, write: if false; // POST /waitlist writes via the admin SDK } // ── Trips (internal utility feature, not org-scoped — see // [[trips-feature-intentional]] and SAAS_PLAN.md B7. Frontend hides // /trips outside the founding org; these rules keep the existing // "public unless flagged private" trip model working for whichever // users the UI still exposes it to) ──────────────────────────────────── match /trips/{tripId} { allow read: if signedIn(); allow write: if false; } match /trip_events/{eventId} { allow read: if signedIn(); allow write: if false; } // ── Deny-by-default catch-all ────────────────────────────────────────── // Anything not explicitly matched above — including collections added // later without a corresponding rule — is denied. This is the guard // rail: a missing rule fails closed, not open. match /{document=**} { allow read, write: if false; } } }