From c7f985df42bff1fc466cfb33068b9fcf52765021 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Tue, 18 Aug 2026 20:33:46 -0400 Subject: [PATCH] Scope every Firestore hook to org_id and stop granting sessions to nobody's org Frontend half of SAAS_PLAN.md B2/B3. The backend commits so far (org_id stamping, Firestore rules) don't protect anything by themselves - every hook in lib/use*.ts reads Firestore directly from the browser (onSnapshot(collection(db, ...))), which is why B1's rules commit called this out as the actual read path in the first place. Until these hooks filter by org_id, the rules just turn "any signed-in user sees everything" into "any signed-in user sees nothing" the moment they're deployed, because nothing supplies the org_id the rules now require. useCalls (all three exports), useIncidents (useIncidents + useActiveIncidents), useNodes, useSystems, and useAlerts (both exports) now pull orgId from AuthProvider and add where("org_id","==",orgId) to their query. If orgId is falsy - not yet resolved, or the account genuinely has no org - each hook returns empty rather than falling back to an unfiltered query, which would silently reopen the exact leak this closes for anyone whose claim hasn't loaded yet. useIncident/useNodes single-doc-by-id reads and useTrips are intentionally untouched: single-doc reads are already covered by the rules directly, and trips has no org_id at all (see the previous commit's trips.py gating - it's staying founding-org-only via B7, not becoming tenant-scoped). AuthProvider grew orgId/orgRole state (read from the org_id/org_role custom claims POST /auth/signup sets) and a refreshClaims() escape hatch for the signup flow to force a claims refetch after provisioning. The load-bearing change is in when it sets the drb_session cookie: only when a claim carries org_id. A signed-in user with no org - the accidental-signup hole SAAS_PLAN.md 2.2/2.3 flagged, where Google sign-in on /login auto-creates a Firebase account with no role or org claim at all - now gets no cookie, which starts them at "no data, by construction" rather than "viewer role, full read access" once combined with the rules deployed earlier. ChromeSwitcher carries the other half of that guard: a signed-in user with no orgId, anywhere outside the marketing pages, gets redirected to /onboarding (added to the frontend in the next commit) instead of letting every page's data hooks just quietly return empty forever. middleware.ts adds /signup and /onboarding to a new no-cookie-gate list, since AuthProvider's cookie logic means an unprovisioned user by definition has no drb_session cookie - gating those two routes on it would bounce exactly the users who need them back to /login before the client-side redirect above ever runs. /terms and /privacy (next-next commit) are pre-added to both PUBLIC_PATHS and ChromeSwitcher's MARKETING_PATHS here so that commit doesn't need to touch routing files. Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy). Co-Authored-By: Claude Opus 5 --- drb-frontend/components/AuthProvider.tsx | 81 +++++++++++++++++----- drb-frontend/components/ChromeSwitcher.tsx | 31 ++++++++- drb-frontend/lib/useAlerts.ts | 18 ++++- drb-frontend/lib/useCalls.ts | 33 +++++++-- drb-frontend/lib/useIncidents.ts | 19 ++++- drb-frontend/lib/useNodes.ts | 13 +++- drb-frontend/lib/useSystems.ts | 14 +++- drb-frontend/middleware.ts | 15 +++- 8 files changed, 186 insertions(+), 38 deletions(-) diff --git a/drb-frontend/components/AuthProvider.tsx b/drb-frontend/components/AuthProvider.tsx index 0696013..9481c73 100644 --- a/drb-frontend/components/AuthProvider.tsx +++ b/drb-frontend/components/AuthProvider.tsx @@ -5,6 +5,8 @@ import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/a import { auth } from "@/lib/firebase"; import type { UserRole } from "@/lib/types"; +export type OrgRole = "owner" | "member"; + interface AuthContextType { user: User | null; loading: boolean; @@ -12,7 +14,13 @@ interface AuthContextType { isAdmin: boolean; isOperator: boolean; ownedNodeIds: string[]; + /** Tenant claim — null means this account isn't provisioned into an org yet. */ + orgId: string | null; + orgRole: OrgRole | null; + isOrgOwner: boolean; signOut: () => Promise; + /** Force-refetch the ID token's claims — call after POST /auth/signup so orgId picks up immediately. */ + refreshClaims: () => Promise; } const AuthContext = createContext({ @@ -22,7 +30,11 @@ const AuthContext = createContext({ isAdmin: false, isOperator: false, ownedNodeIds: [], + orgId: null, + orgRole: null, + isOrgOwner: false, signOut: async () => {}, + refreshClaims: async () => {}, }); export function AuthProvider({ children }: { children: React.ReactNode }) { @@ -30,34 +42,60 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [loading, setLoading] = useState(true); const [role, setRole] = useState(null); const [ownedNodeIds, setOwnedNodeIds] = useState([]); + const [orgId, setOrgId] = useState(null); + const [orgRole, setOrgRole] = useState(null); + + async function applyClaims(u: User, forceRefresh: boolean) { + const result = await u.getIdTokenResult(forceRefresh); + const claims = result.claims; + + // Derive role: prefer granular "role" claim, fall back to legacy "admin" boolean + let effectiveRole: UserRole = "viewer"; + if (claims.role === "admin" || claims.admin) { + effectiveRole = "admin"; + } else if (claims.role === "operator") { + effectiveRole = "operator"; + } else if (claims.role === "viewer") { + effectiveRole = "viewer"; + } + + setRole(effectiveRole); + setOwnedNodeIds((claims.owned_node_ids as string[]) ?? []); + + // org_id/org_role are set by POST /auth/signup. No org_id claim means + // this account was created (e.g. via Google sign-in's implicit account + // creation) but never provisioned — see the no-claim guard below, which + // is what stops that from being a live data exposure. + const claimOrgId = typeof claims.org_id === "string" ? claims.org_id : null; + const claimOrgRole = claims.org_role === "owner" || claims.org_role === "member" ? claims.org_role : null; + setOrgId(claimOrgId); + setOrgRole(claimOrgRole); + + // drb_session is only a UX redirect signal (middleware.ts), not a + // security boundary (see CLAUDE.md) — but it must not be set for an + // unprovisioned account, or the middleware will wave them straight into + // /dashboard instead of /onboarding. + if (claimOrgId) { + document.cookie = "drb_session=1; path=/; SameSite=Strict"; + } else { + document.cookie = "drb_session=; path=/; max-age=0"; + } + } useEffect(() => { return onAuthStateChanged(auth, async (u) => { setUser(u); - setLoading(false); if (u) { - document.cookie = "drb_session=1; path=/; SameSite=Strict"; - const result = await u.getIdTokenResult(true); - const claims = result.claims; - - // Derive role: prefer granular "role" claim, fall back to legacy "admin" boolean - let effectiveRole: UserRole = "viewer"; - if (claims.role === "admin" || claims.admin) { - effectiveRole = "admin"; - } else if (claims.role === "operator") { - effectiveRole = "operator"; - } else if (claims.role === "viewer") { - effectiveRole = "viewer"; - } - - setRole(effectiveRole); - setOwnedNodeIds((claims.owned_node_ids as string[]) ?? []); + await applyClaims(u, true); } else { document.cookie = "drb_session=; path=/; max-age=0"; setRole(null); setOwnedNodeIds([]); + setOrgId(null); + setOrgRole(null); } + setLoading(false); }); }, []); @@ -66,11 +104,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { document.cookie = "drb_session=; path=/; max-age=0"; } + async function refreshClaims() { + if (auth.currentUser) await applyClaims(auth.currentUser, true); + } + const isAdmin = role === "admin"; const isOperator = role === "operator"; + const isOrgOwner = orgRole === "owner"; return ( - + {children} ); diff --git a/drb-frontend/components/ChromeSwitcher.tsx b/drb-frontend/components/ChromeSwitcher.tsx index 20034d8..2e9ff24 100644 --- a/drb-frontend/components/ChromeSwitcher.tsx +++ b/drb-frontend/components/ChromeSwitcher.tsx @@ -1,22 +1,49 @@ "use client"; -import { usePathname } from "next/navigation"; +import { useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { useAuth } from "@/components/AuthProvider"; import { Nav } from "@/components/Nav"; import { MarketingHeader } from "@/components/marketing/MarketingHeader"; import { MarketingFooter } from "@/components/marketing/MarketingFooter"; // Public marketing surface — exact paths, not prefixes, so e.g. /features/x // (if it ever exists) doesn't accidentally get pulled into marketing chrome. -const MARKETING_PATHS = new Set(["/", "/features", "/pricing", "/faq"]); +// Keep in sync with PUBLIC_PATHS in middleware.ts (that one decides whether +// to redirect at all; this one just picks page chrome). +const MARKETING_PATHS = new Set(["/", "/features", "/pricing", "/faq", "/terms", "/privacy"]); + +// Pages a signed-in user with no org_id claim must still be able to reach — +// otherwise the redirect below would loop against itself, or lock someone +// out of the one screen (/onboarding) that fixes their account. +const NO_ORG_ALLOWED_PATHS = new Set(["/onboarding", "/login", "/signup", "/profile"]); /** * Picks page chrome by route: the public marketing pages get a full-bleed * layout with their own header/footer, everything else (the authenticated * app, including /login and /settings) keeps the existing app Nav + padded * main container. + * + * Also carries AuthProvider's no-claim guard (SAAS_PLAN.md B3): a signed-in + * Firebase user with no org_id claim is a real session that is nonetheless + * provisioned into nothing — AuthProvider already refuses to set the + * drb_session cookie for them, so middleware.ts's redirect only covers + * "not signed in at all". This effect covers the other case: signed in, no + * org, anywhere in the app — send them to /onboarding rather than letting + * every page's data hooks fail open or silently return nothing. */ export function ChromeSwitcher({ children }: { children: React.ReactNode }) { const pathname = usePathname(); + const { user, loading, orgId } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (loading) return; + if (!user) return; // not signed in — middleware.ts already routes this to /login + if (orgId) return; + if (MARKETING_PATHS.has(pathname) || NO_ORG_ALLOWED_PATHS.has(pathname)) return; + router.replace("/onboarding"); + }, [loading, user, orgId, pathname, router]); if (MARKETING_PATHS.has(pathname)) { return ( diff --git a/drb-frontend/lib/useAlerts.ts b/drb-frontend/lib/useAlerts.ts index 7c85d47..5826dd2 100644 --- a/drb-frontend/lib/useAlerts.ts +++ b/drb-frontend/lib/useAlerts.ts @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore"; import { onAuthStateChanged } from "firebase/auth"; import { db, auth } from "@/lib/firebase"; +import { useAuth } from "@/components/AuthProvider"; import type { AlertEvent } from "@/lib/types"; const toISO = (v: unknown): string => @@ -14,6 +15,7 @@ export function useAlerts(limitCount = 50) { const [alerts, setAlerts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { orgId } = useAuth(); useEffect(() => { let unsubFirestore: (() => void) | undefined; @@ -26,9 +28,15 @@ export function useAlerts(limitCount = 50) { setLoading(false); return; } + if (!orgId) { + setAlerts([]); + setLoading(false); + return; + } const q = query( collection(db, "alert_events"), + where("org_id", "==", orgId), orderBy("triggered_at", "desc"), limit(limitCount) ); @@ -52,13 +60,14 @@ export function useAlerts(limitCount = 50) { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; - }, [limitCount]); + }, [limitCount, orgId]); return { alerts, loading, error }; } export function useUnacknowledgedAlerts() { const [alerts, setAlerts] = useState([]); + const { orgId } = useAuth(); useEffect(() => { let unsubFirestore: (() => void) | undefined; @@ -70,9 +79,14 @@ export function useUnacknowledgedAlerts() { setAlerts([]); return; } + if (!orgId) { + setAlerts([]); + return; + } const q = query( collection(db, "alert_events"), + where("org_id", "==", orgId), where("acknowledged", "==", false), orderBy("triggered_at", "desc"), limit(100) @@ -89,7 +103,7 @@ export function useUnacknowledgedAlerts() { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; - }, []); + }, [orgId]); return alerts; } diff --git a/drb-frontend/lib/useCalls.ts b/drb-frontend/lib/useCalls.ts index e118da6..6af6aa1 100644 --- a/drb-frontend/lib/useCalls.ts +++ b/drb-frontend/lib/useCalls.ts @@ -4,12 +4,14 @@ import { useEffect, useState } from "react"; import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore"; import { onAuthStateChanged } from "firebase/auth"; import { db, auth } from "@/lib/firebase"; +import { useAuth } from "@/components/AuthProvider"; import type { CallRecord } from "@/lib/types"; export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) { const [calls, setCalls] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { orgId } = useAuth(); // Stable ms values so the effect dependency doesn't fire on every render const dateFromMs = dateFrom?.getTime(); @@ -26,11 +28,21 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) { setLoading(false); return; } + // No org_id claim yet (still resolving, or genuinely unprovisioned — + // see ChromeSwitcher's no-claim guard) — an unfiltered query here + // would be exactly the cross-tenant read this scoping exists to + // close, so wait rather than fall back to "query everything". + if (!orgId) { + setCalls([]); + setLoading(false); + return; + } const from = dateFromMs != null ? new Date(dateFromMs) : undefined; const to = dateToMs != null ? new Date(dateToMs) : undefined; const constraints = [ + where("org_id", "==", orgId), ...(from ? [where("started_at", ">=", from)] : []), ...(to ? [where("started_at", "<=", to)] : []), orderBy("started_at", "desc"), @@ -52,7 +64,7 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; - }, [limitCount, dateFromMs, dateToMs]); + }, [limitCount, dateFromMs, dateToMs, orgId]); return { calls, loading, error }; } @@ -60,6 +72,7 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) { export function useCallsByIncident(incidentId: string | null) { const [calls, setCalls] = useState([]); const [loading, setLoading] = useState(true); + const { orgId } = useAuth(); useEffect(() => { if (!incidentId) { setLoading(false); return; } @@ -68,11 +81,16 @@ export function useCallsByIncident(incidentId: string | null) { const unsubAuth = onAuthStateChanged(auth, (user) => { if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; } if (!user) { setLoading(false); return; } + if (!orgId) { setCalls([]); setLoading(false); return; } const toISO = (v: any): string | null => v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null); - const q = query(collection(db, "calls"), where("incident_ids", "array-contains", incidentId)); + const q = query( + collection(db, "calls"), + where("org_id", "==", orgId), + where("incident_ids", "array-contains", incidentId) + ); unsubFirestore = onSnapshot(q, (snap) => { const docs = snap.docs.map((d) => { const data = d.data(); @@ -85,13 +103,14 @@ export function useCallsByIncident(incidentId: string | null) { }); return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; - }, [incidentId]); + }, [incidentId, orgId]); return { calls, loading }; } export function useActiveCalls() { const [calls, setCalls] = useState([]); + const { orgId } = useAuth(); useEffect(() => { let unsubFirestore: (() => void) | undefined; @@ -103,8 +122,12 @@ export function useActiveCalls() { setCalls([]); return; } + if (!orgId) { + setCalls([]); + return; + } - const q = query(collection(db, "calls"), where("status", "==", "active")); + const q = query(collection(db, "calls"), where("org_id", "==", orgId), where("status", "==", "active")); const toISO = (v: any): string | null => v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null); unsubFirestore = onSnapshot(q, (snap) => { @@ -119,7 +142,7 @@ export function useActiveCalls() { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; - }, []); + }, [orgId]); return calls; } diff --git a/drb-frontend/lib/useIncidents.ts b/drb-frontend/lib/useIncidents.ts index f2714f0..55446fa 100644 --- a/drb-frontend/lib/useIncidents.ts +++ b/drb-frontend/lib/useIncidents.ts @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { collection, doc, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore"; import { onAuthStateChanged } from "firebase/auth"; import { db, auth } from "@/lib/firebase"; +import { useAuth } from "@/components/AuthProvider"; import type { IncidentRecord } from "@/lib/types"; const toISO = (v: unknown): string => @@ -14,6 +15,7 @@ export function useIncidents(limitCount = 100) { const [incidents, setIncidents] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { orgId } = useAuth(); useEffect(() => { let unsubFirestore: (() => void) | undefined; @@ -26,9 +28,15 @@ export function useIncidents(limitCount = 100) { setLoading(false); return; } + if (!orgId) { + setIncidents([]); + setLoading(false); + return; + } const q = query( collection(db, "incidents"), + where("org_id", "==", orgId), orderBy("started_at", "desc"), limit(limitCount) ); @@ -53,7 +61,7 @@ export function useIncidents(limitCount = 100) { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; - }, [limitCount]); + }, [limitCount, orgId]); return { incidents, loading, error }; } @@ -97,6 +105,7 @@ export function useIncident(incidentId: string | null) { export function useActiveIncidents() { const [incidents, setIncidents] = useState([]); + const { orgId } = useAuth(); useEffect(() => { let unsubFirestore: (() => void) | undefined; @@ -108,8 +117,12 @@ export function useActiveIncidents() { setIncidents([]); return; } + if (!orgId) { + setIncidents([]); + return; + } - const q = query(collection(db, "incidents"), where("status", "==", "active")); + const q = query(collection(db, "incidents"), where("org_id", "==", orgId), where("status", "==", "active")); unsubFirestore = onSnapshot(q, (snap) => { setIncidents(snap.docs.map((d) => { const data = d.data(); @@ -126,7 +139,7 @@ export function useActiveIncidents() { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; - }, []); + }, [orgId]); return incidents; } diff --git a/drb-frontend/lib/useNodes.ts b/drb-frontend/lib/useNodes.ts index 7f3ba33..b3aca79 100644 --- a/drb-frontend/lib/useNodes.ts +++ b/drb-frontend/lib/useNodes.ts @@ -1,15 +1,17 @@ "use client"; import { useEffect, useState } from "react"; -import { collection, onSnapshot, query, FirestoreError } from "firebase/firestore"; +import { collection, onSnapshot, query, where, FirestoreError } from "firebase/firestore"; import { onAuthStateChanged } from "firebase/auth"; import { db, auth } from "@/lib/firebase"; +import { useAuth } from "@/components/AuthProvider"; import type { NodeRecord } from "@/lib/types"; export function useNodes() { const [nodes, setNodes] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { orgId } = useAuth(); useEffect(() => { let unsubFirestore: (() => void) | undefined; @@ -22,8 +24,13 @@ export function useNodes() { setLoading(false); return; } + if (!orgId) { + setNodes([]); + setLoading(false); + return; + } - const q = query(collection(db, "nodes")); + const q = query(collection(db, "nodes"), where("org_id", "==", orgId)); unsubFirestore = onSnapshot(q, (snap) => { setNodes(snap.docs.map((d) => d.data() as NodeRecord)); setLoading(false); @@ -34,7 +41,7 @@ export function useNodes() { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; - }, []); + }, [orgId]); return { nodes, loading, error }; } diff --git a/drb-frontend/lib/useSystems.ts b/drb-frontend/lib/useSystems.ts index fb05613..89a8ac4 100644 --- a/drb-frontend/lib/useSystems.ts +++ b/drb-frontend/lib/useSystems.ts @@ -1,15 +1,17 @@ "use client"; import { useEffect, useState } from "react"; -import { collection, onSnapshot, FirestoreError } from "firebase/firestore"; +import { collection, onSnapshot, query, where, FirestoreError } from "firebase/firestore"; import { onAuthStateChanged } from "firebase/auth"; import { db, auth } from "@/lib/firebase"; +import { useAuth } from "@/components/AuthProvider"; import type { SystemRecord } from "@/lib/types"; export function useSystems() { const [systems, setSystems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { orgId } = useAuth(); useEffect(() => { let unsubFirestore: (() => void) | undefined; @@ -22,8 +24,14 @@ export function useSystems() { setLoading(false); return; } + if (!orgId) { + setSystems([]); + setLoading(false); + return; + } - unsubFirestore = onSnapshot(collection(db, "systems"), (snap) => { + const q = query(collection(db, "systems"), where("org_id", "==", orgId)); + unsubFirestore = onSnapshot(q, (snap) => { setSystems(snap.docs.map((d) => d.data() as SystemRecord)); setLoading(false); }, (err: FirestoreError) => { console.error("useSystems:", err); setError(err.message); setLoading(false); }); @@ -33,7 +41,7 @@ export function useSystems() { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; - }, []); + }, [orgId]); return { systems, loading, error }; } diff --git a/drb-frontend/middleware.ts b/drb-frontend/middleware.ts index 3577c3a..6cff905 100644 --- a/drb-frontend/middleware.ts +++ b/drb-frontend/middleware.ts @@ -3,7 +3,18 @@ import { NextRequest, NextResponse } from "next/server"; // Public marketing pages — no session required. Keep this in sync with // MARKETING_PATHS in components/ChromeSwitcher.tsx (that one picks page // chrome; this one decides whether to redirect at all). -const PUBLIC_PATHS = new Set(["/", "/features", "/pricing", "/faq"]); +const PUBLIC_PATHS = new Set(["/", "/features", "/pricing", "/faq", "/terms", "/privacy"]); + +// /signup and /onboarding are deliberately NOT gated by the drb_session +// cookie here, even though they aren't "public" in the sense of not needing +// an account — AuthProvider only sets that cookie once a user has an org_id +// claim (SAAS_PLAN.md B3's no-claim guard), and /onboarding exists +// specifically for a signed-in user who doesn't have one yet. Gating it on +// the same cookie would bounce the exact users who need it back to /login +// before ChromeSwitcher's client-side redirect ever runs. Both pages do +// their own client-side auth check (redirect to /login if genuinely signed +// out) instead. +const NO_SESSION_COOKIE_GATE = new Set(["/signup", "/onboarding"]); // NOTE: this is a UX redirect only, not a security boundary — it just checks // a client-set cookie's presence. Real enforcement is server-side, in @@ -12,7 +23,7 @@ export function middleware(request: NextRequest) { const session = request.cookies.get("drb_session"); const { pathname } = request.nextUrl; - if (PUBLIC_PATHS.has(pathname)) { + if (PUBLIC_PATHS.has(pathname) || NO_SESSION_COOKIE_GATE.has(pathname)) { return NextResponse.next(); }