Files
server-26/infra/firestore/firestore.rules
T
Logan CusanoandClaude Sonnet 5 5537b095df Wire ADS-B end to end: telemetry ingestion + live map overlay
node-26#9. Adds POST /telemetry/adsb (node-key authed via
require_node_service_or_firebase_token) that upserts one Firestore doc per
icao into a new `aircraft` collection, org_id stamped from the reporting
node the same way upload.py defensively stamps `calls`. firestore.rules
gets a matching docInMyOrg()-gated read rule.

Frontend: useAircraft() mirrors useNodes()'s onSnapshot pattern, filtering
docs older than 2 minutes client-side since nothing prunes a stale aircraft
doc server-side yet. MapView gets an opt-in "Aircraft" overlay (unchecked
by default, like the weather radar layer) rendering a rotated plane glyph
per sighting.

Unverified via typecheck — no Node.js/npm on this authoring box yet (see
CLAUDE.md Testing reality). Server side is pytest-covered (test_telemetry.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 16:08:29 -04:00

174 lines
6.6 KiB
Plaintext

// 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 <project-id>
// (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;
}
// Live map overlays fed by a node's second SDR (node-26#9). Snapshot
// docs, one per icao/mmsi, last-seen-wins — not a history collection.
match /aircraft/{icao} {
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;
}
}
}