The previous commit shipped Firestore rules that reference an org_id claim
nothing issues yet, and an org_id filter nothing writes yet - this is the
commit that makes both real. Backend half of SAAS_PLAN.md B2/B2b/B2c.
Data model: organizations/{org_id} and org_members/{uid} are new
collections (models.py OrganizationRecord/OrgMember). org_id is now an
Optional field on NodeRecord, SystemRecord, CallRecord, IncidentRecord,
AlertRule, and AlertEvent - optional because every existing document
predates it; scripts/backfill_org_id.py (written, not run - it touches
production Firestore and Firebase Auth claims) is what closes that gap
later. plan_id/subscription_status/stripe_* on OrganizationRecord are
deliberately None: no billing or pricing model has been decided, so this is
a seam, not a promise. app/internal/tenancy.py holds FOUNDING_ORG_ID, the
org every pre-tenancy document and every legacy enrollment path resolves
into.
Where org_id comes from, end to end: a customer's node enrolls with a
per-org token (new enrollment_tokens/{token_hash} collection, minted via
POST /org/enrollment-tokens - new routers/org.py) instead of the old
fleet-wide ENROLLMENT_TOKEN, which still works as a fallback that resolves
to FOUNDING_ORG_ID so an already-deployed node's .env doesn't start failing
today. The node's org_id then flows onto every call it produces
(mqtt_handler.py's call_start/call_end, upload.py's /upload handler all
resolve it from the node doc), and onto every incident correlated from
those calls (incident_correlator.py's _create_incident/_create_master_incident).
That last one is the part that isn't just a read filter: _build_context's
`all_active = collection_list("incidents", status="active")` fed every
correlation candidate - fast-path talkgroup match, unit-continuity,
disambiguation - from the entire incidents collection, unscoped. Without
scoping it to the call's own org_id, a call from org A could link into an
incident org B already owns, which is a cross-tenant data merge at
correlation time, not just an over-broad read. Same shape of bug in
alerter.py: rule matching pulled every enabled alert_rule regardless of
org, so org A's keyword rule could fire (and POST org A's Discord webhook)
on org B's radio traffic. Both now resolve org_id from the call doc itself
rather than threading a new parameter through every caller.
Every list/get route gained org scoping via a new resolve_caller_org_id()
helper in internal/auth.py, which handles the three credential shapes those
routes accept (service key, node api_key, Firebase user) uniformly and
returns None (unrestricted) for the service key and platform admins -
preserving today's single-org behaviour exactly while closing the leak for
everyone else: GET /nodes, /systems, /calls, /incidents, /alerts,
/alert-rules. Write routes for nodes/systems (approve, create, delete, etc.)
deliberately stay platform-admin-only for now rather than being loosened to
org-owner/operator - that's a real gap called out in SAAS_PLAN.md 2.4's
"should be" column, but it's a separate authorization redesign the 12-item
build order doesn't actually enumerate, and doing it half-considered here
risked being exactly the "half-applied filter is worse than none" failure
mode the plan warns about. Today's founding org keeps working unchanged;
loosening node/system management to org owners is follow-up work, flagged
rather than guessed at.
Also closed the four spend/access-attack routes SAAS_PLAN.md B2c called out
by file and line: POST /calls/{id}/reprocess is now admin-only (was any
signed-in viewer looping the Whisper+Gemini pipeline for free - DEFERRED.md
had this as a live, independent-of-SaaS exploit) plus a per-call rate
limiter as a second guard; POST /alerts/{id}/acknowledge now checks the
alert's org_id; GET /admin/features moved from require_firebase_token to
require_admin_token; and trips.py's four unauthenticated mutation routes
(create_trip, update_trip_tags, create_event, update_event) are now
restricted to the founding org (or the bot's service key, or a platform
admin) - trips has no org_id of its own and isn't getting one, since
[[trips-feature-intentional]] says it's an internal utility riding along on
this stack, not a tenant-scoped product surface.
New public-but-scoped seam: POST /auth/signup (routers/links.py, alongside
the existing /auth/link* routes) provisions an organizations doc and an
owner org_members doc for a just-created Firebase user, then sets their
org_id/org_role claims - idempotent, so a double-submit doesn't create two
orgs. This is the only route that turns "has a Firebase account" into "can
read anything," which is what the frontend AuthProvider no-claim guard
(next commit) is built around.
Also new: GET/PATCH /org for the organization profile (closes the disabled
"Save changes" button noted in DEFERRED.md - there was no organizations
concept to save into before this), and POST /waitlist (public, source-IP
rate-limited, not coupled to any plan or tier - the commercial model is
still an open decision per SAAS_PLAN.md section 6).
Verified: all touched files py_compile clean; c2-core pytest is 69
passed / 10 failed, matching the documented pre-existing baseline exactly
(DEFERRED.md - mqtt_handler/node_sweeper test-vs-code drift, unrelated to
this change) - no new failures. flake8 --max-line-length=120 shows no new
violations in any touched file (checked each new E501/E221/E30x against
`git diff` to confirm it predates this commit); c2-core has no CI lint gate
regardless (CLAUDE.md - flake8 only runs in Client CI).
No new environment variables. Firestore composite indexes for the queries
this introduces were already shipped in the previous commit
(infra/firestore/firestore.indexes.json).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
265 lines
12 KiB
Python
265 lines
12 KiB
Python
import secrets
|
|
import time
|
|
from collections import defaultdict, deque
|
|
from typing import Optional
|
|
from fastapi import HTTPException, Security
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
from firebase_admin import auth as firebase_auth
|
|
from app.config import settings
|
|
|
|
_bearer = HTTPBearer(auto_error=False)
|
|
|
|
|
|
async def require_firebase_token(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
) -> dict:
|
|
"""Verify a Firebase ID token from the Authorization: Bearer header."""
|
|
if not credentials:
|
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
|
try:
|
|
return firebase_auth.verify_id_token(credentials.credentials)
|
|
except Exception:
|
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
|
|
|
|
async def require_service_or_firebase_token(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
) -> dict:
|
|
"""Accept either a Firebase ID token or the internal service key."""
|
|
if not credentials:
|
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
|
token = credentials.credentials
|
|
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
|
return {"service": True}
|
|
try:
|
|
return firebase_auth.verify_id_token(token)
|
|
except Exception:
|
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
|
|
|
|
async def require_node_service_or_firebase_token(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
) -> dict:
|
|
"""Accept a node's own API key in addition to a service key / Firebase token.
|
|
|
|
Edge nodes need to read ``/systems`` to build their OP25 config, but they
|
|
hold neither a Firebase token nor the shared service key — only the
|
|
per-node api_key that ``/upload`` already trusts. Without this they got a
|
|
flat 401 and silently fell back to their stale offline cache, so a system
|
|
edited in the UI never reached the node.
|
|
|
|
Unlike ``/upload``, the node sends no node_id alongside the bearer token,
|
|
so the key is matched by querying ``node_keys`` for the value rather than
|
|
fetching a known document. Mutating routes are unaffected: they carry
|
|
their own ``require_admin_token`` dependency, so widening the router-level
|
|
gate grants nodes read access only.
|
|
"""
|
|
if not credentials:
|
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
|
token = credentials.credentials
|
|
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
|
return {"service": True}
|
|
try:
|
|
return firebase_auth.verify_id_token(token)
|
|
except Exception:
|
|
pass
|
|
# Deferred import: app.internal.firestore initialises firebase-admin at
|
|
# import time, and auth.py is imported from module scope in the routers.
|
|
from app.internal import firestore as fstore
|
|
matches = await fstore.collection_list("node_keys", api_key=token)
|
|
if matches:
|
|
return {"node": True, "node_id": matches[0].get("node_id")}
|
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
|
|
|
|
def get_role(decoded: dict) -> str:
|
|
"""Extract the effective role from a decoded Firebase token.
|
|
|
|
Checks the granular ``role`` claim first, then falls back to the legacy
|
|
``admin`` boolean so existing tokens continue to work during the transition.
|
|
"""
|
|
if decoded.get("role") == "admin" or decoded.get("admin"):
|
|
return "admin"
|
|
role = decoded.get("role", "viewer")
|
|
return role if role in ("admin", "operator", "viewer") else "viewer"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tenancy — org_id / org_role claims, set by POST /auth/signup (routers/links.py)
|
|
# ---------------------------------------------------------------------------
|
|
# `role` above is platform-level (admin/operator/viewer — unrelated to which
|
|
# org a user belongs to). `org_role` is the customer-facing one: "owner" or
|
|
# "member" of the org named by the `org_id` claim. See SAAS_PLAN.md B2/B4.
|
|
|
|
def get_org_role(decoded: dict) -> Optional[str]:
|
|
org_role = decoded.get("org_role")
|
|
return org_role if org_role in ("owner", "member") else None
|
|
|
|
|
|
def require_org(decoded: dict) -> str:
|
|
"""Return the caller's org_id claim, or 403 if they don't have one.
|
|
|
|
A Firebase token with no org_id claim is a real, valid session (the user
|
|
signed in) that is nonetheless provisioned into nothing — see
|
|
AuthProvider's no-claim guard (SAAS_PLAN.md B3). Every org-scoped route
|
|
depends on this rather than trusting a client-supplied org_id, so a
|
|
caller can never read/write outside the org their own token names.
|
|
"""
|
|
org_id = decoded.get("org_id")
|
|
if not org_id:
|
|
raise HTTPException(403, "This account is not associated with an organization.")
|
|
return org_id
|
|
|
|
|
|
def resolve_org_scope(decoded: dict, org_id_override: Optional[str] = None) -> str:
|
|
"""Return the org_id a request should be scoped to.
|
|
|
|
Platform admins (role == "admin") may pass ?org_id=<id> to cross into
|
|
another org's data for support/debugging — the one exception to "you can
|
|
only ever see your own org's data" called out in SAAS_PLAN.md B2. Every
|
|
other caller is locked to their own token's org_id claim regardless of
|
|
what (if anything) they pass.
|
|
"""
|
|
if org_id_override and get_role(decoded) == "admin":
|
|
return org_id_override
|
|
return require_org(decoded)
|
|
|
|
|
|
async def resolve_caller_org_id(decoded: dict) -> Optional[str]:
|
|
"""
|
|
Resolve the org_id a caller should be scoped to, across every credential
|
|
shape this file's dependencies can produce (service key, node api_key,
|
|
Firebase user) — a single helper so read routes gated by
|
|
require_service_or_firebase_token / require_node_service_or_firebase_token
|
|
don't each need their own caller-shape switch.
|
|
|
|
Returns None for callers that should see across every org: the internal
|
|
service key (the Discord bot — a single fleet-wide principal, see
|
|
CLAUDE.md's auth section) and platform admins, matching
|
|
require_admin_token's existing "admin sees everything" behaviour. A
|
|
route that wants admins scoped too should check get_role() itself rather
|
|
than relying on this function to do it.
|
|
"""
|
|
if decoded.get("service"):
|
|
return None
|
|
if decoded.get("node"):
|
|
# Deferred import — same reasoning as require_node_service_or_firebase_token
|
|
# above: app.internal.firestore initialises firebase-admin at import
|
|
# time, and auth.py is imported from module scope in the routers.
|
|
from app.internal import firestore as fstore
|
|
node = await fstore.doc_get_cached("nodes", decoded.get("node_id") or "")
|
|
return (node or {}).get("org_id")
|
|
if get_role(decoded) == "admin":
|
|
return None
|
|
return require_org(decoded)
|
|
|
|
|
|
async def require_org_owner_token(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
) -> dict:
|
|
"""Verify a Firebase ID token AND require org_role == "owner" (or platform admin).
|
|
|
|
Used for org-administrative actions a regular member shouldn't be able to
|
|
do on their own org — minting/revoking enrollment tokens, renaming the
|
|
org. Platform admins pass through regardless of org_role so support can
|
|
act on an org that has no reachable owner.
|
|
"""
|
|
decoded = await require_firebase_token(credentials)
|
|
require_org(decoded)
|
|
if get_org_role(decoded) != "owner" and get_role(decoded) != "admin":
|
|
raise HTTPException(status_code=403, detail="Organization owner access required.")
|
|
return decoded
|
|
|
|
|
|
async def require_admin_token(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
) -> dict:
|
|
"""Verify a Firebase ID token AND require the admin role.
|
|
|
|
Accepts both the legacy ``admin: True`` boolean claim and the newer
|
|
``role: "admin"`` claim so tokens issued before the role migration still work.
|
|
"""
|
|
decoded = await require_firebase_token(credentials)
|
|
if get_role(decoded) != "admin":
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
return decoded
|
|
|
|
|
|
async def require_service_key(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
) -> dict:
|
|
"""Accept only the internal service key — used for bot-only endpoints."""
|
|
if not credentials:
|
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
|
if not settings.service_key:
|
|
raise HTTPException(status_code=503, detail="Service key not configured")
|
|
if not secrets.compare_digest(credentials.credentials, settings.service_key):
|
|
raise HTTPException(status_code=403, detail="Service key required")
|
|
return {"service": True}
|
|
|
|
|
|
async def require_service_key_or_admin(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
) -> dict:
|
|
"""Accept either the internal service key or a Firebase admin token.
|
|
|
|
Used for endpoints that the Discord bot (service key) and dashboard admins
|
|
(Firebase + admin claim) both need to call, but regular Firebase users must not.
|
|
"""
|
|
if not credentials:
|
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
|
token = credentials.credentials
|
|
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
|
return {"service": True}
|
|
try:
|
|
decoded = firebase_auth.verify_id_token(token)
|
|
except Exception:
|
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
if get_role(decoded) != "admin":
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
return decoded
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Simple in-memory sliding-window rate limiter
|
|
# ---------------------------------------------------------------------------
|
|
# Not persistent across restarts; good enough for a single-instance deployment.
|
|
# Key format is caller-defined (e.g. "{uid}:{endpoint}").
|
|
|
|
class _RateLimiter:
|
|
def __init__(self, max_calls: int, window_seconds: int):
|
|
self.max_calls = max_calls
|
|
self.window = window_seconds
|
|
self._log: dict[str, deque] = defaultdict(deque)
|
|
|
|
def check(self, key: str) -> None:
|
|
now = time.monotonic()
|
|
q = self._log[key]
|
|
while q and now - q[0] > self.window:
|
|
q.popleft()
|
|
if len(q) >= self.max_calls:
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail="Rate limit exceeded. Please wait before trying again.",
|
|
)
|
|
q.append(now)
|
|
|
|
|
|
# Shared limiter instances
|
|
# trip chat: 20 requests per user per 5 minutes
|
|
trip_chat_limiter = _RateLimiter(max_calls=20, window_seconds=300)
|
|
# per-incident summarize: 5 per incident per 10 minutes
|
|
summarize_limiter = _RateLimiter(max_calls=5, window_seconds=600)
|
|
# vocabulary bootstrap: 2 per system per hour
|
|
bootstrap_limiter = _RateLimiter(max_calls=2, window_seconds=3600)
|
|
# per-call reprocess: 3 per call per 10 minutes — reprocess re-runs the full
|
|
# Whisper + Gemini pipeline, which is real spend per call; this is now also
|
|
# admin-only (see routers/calls.py) but the limiter stays as a second guard
|
|
# against a compromised/careless admin session looping it. Keyed by call_id,
|
|
# same pattern as summarize_limiter.
|
|
reprocess_limiter = _RateLimiter(max_calls=3, window_seconds=600)
|
|
# public waitlist submissions: 5 per source IP per hour — POST /waitlist has
|
|
# no auth at all by design (SAAS_PLAN.md B6), so this is the only thing
|
|
# standing between it and being spammed.
|
|
waitlist_limiter = _RateLimiter(max_calls=5, window_seconds=3600)
|