Stamp org_id everywhere and gate every route that leaked across tenants
Build & Deploy / Build & push images (push) Successful in 4m5s
Build & Deploy / Deploy to VM (push) Successful in 1m59s

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>
This commit is contained in:
Logan Cusano
2026-08-18 20:28:37 -04:00
co-authored by Claude Opus 5
parent 74faa55396
commit a3681ea698
20 changed files with 889 additions and 45 deletions
+17 -1
View File
@@ -27,7 +27,22 @@ async def check_and_dispatch(
Check all enabled alert rules and fire events for any that match this call.
"""
try:
rules = await fstore.collection_list("alert_rules", enabled=True)
# Scoped to the call's own org — an unscoped query here would let an
# alert rule created by one org fire (and POST its Discord webhook)
# on another org's radio traffic. org_id is resolved from the call
# doc rather than threaded through as a new parameter, since every
# caller of check_and_dispatch already has call_id and the call doc
# is the single source of truth for a call's org once mqtt_handler.py
# / upload.py have stamped it. None only for a call from a node that
# predates tenancy and hasn't been through the backfill script yet —
# such calls fall back to the pre-tenancy behaviour of checking
# every rule regardless of org.
call_doc = await fstore.doc_get("calls", call_id)
org_id = (call_doc or {}).get("org_id")
if org_id is not None:
rules = await fstore.collection_list("alert_rules", enabled=True, org_id=org_id)
else:
rules = await fstore.collection_list("alert_rules", enabled=True)
except Exception as e:
logger.warning(f"Alerter: could not load rules: {e}")
return
@@ -42,6 +57,7 @@ async def check_and_dispatch(
now = datetime.now(timezone.utc).isoformat()
event = {
"alert_id": alert_id,
"org_id": org_id,
"rule_id": rule.get("rule_id", ""),
"rule_name": rule.get("name", ""),
"call_id": call_id,
+97
View File
@@ -84,6 +84,93 @@ def get_role(decoded: dict) -> str:
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:
@@ -165,3 +252,13 @@ trip_chat_limiter = _RateLimiter(max_calls=20, window_seconds=300)
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)
@@ -335,10 +335,23 @@ async def _build_context(
now = reference_time or datetime.now(timezone.utc)
window = timedelta(hours=settings.correlation_window_hours)
all_active = await fstore.collection_list("incidents", status="active")
call_doc = await fstore.doc_get("calls", call_id) or {}
org_id = call_doc.get("org_id")
# Candidate incidents MUST be scoped to the call's own org — without this,
# a call from org A could match/link into an incident belonging to org B
# (fast-path talkgroup match, unit-continuity, disambiguation all pull
# from all_active/recent below), which is a cross-tenant data merge, not
# just an over-broad read. org_id is None for a call from a node that
# predates tenancy and hasn't been through scripts/backfill_org_id.py yet
# — such calls fall back to the pre-tenancy behaviour of matching across
# the whole collection rather than being unable to correlate at all.
if org_id is not None:
all_active = await fstore.collection_list("incidents", status="active", org_id=org_id)
else:
all_active = await fstore.collection_list("incidents", status="active")
recent = [inc for inc in all_active if _within_window_of(inc, now, window)]
call_doc = await fstore.doc_get("calls", call_id) or {}
call_embedding = call_doc.get("embedding")
call_units = units if units is not None else (call_doc.get("units") or [])
call_vehicles = vehicles if vehicles is not None else (call_doc.get("vehicles") or [])
@@ -348,7 +361,7 @@ async def _build_context(
is_thin_call = not call_units and not call_vehicles and not coords
return {
"call_id": call_id, "all_active": all_active, "recent": recent,
"call_id": call_id, "org_id": org_id, "all_active": all_active, "recent": recent,
"call_doc": call_doc, "call_embedding": call_embedding,
"call_units": call_units, "call_vehicles": call_vehicles,
"call_cleared": call_cleared, "call_severity": call_severity,
@@ -839,6 +852,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
return None
call_id = ctx["call_id"]
org_id = ctx["org_id"]
talkgroup_id = ctx["talkgroup_id"]
talkgroup_name = ctx["talkgroup_name"]
system_id = ctx["system_id"]
@@ -891,7 +905,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
# Create the new agency's child incident first
incident_id = await _create_incident(
call_id, incident_type, talkgroup_id, talkgroup_name, system_id,
call_id, org_id, incident_type, talkgroup_id, talkgroup_name, system_id,
tags, location, location_coords,
call_units, call_vehicles, call_embedding, call_severity, now,
)
@@ -910,6 +924,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
master_id = await _create_master_incident(
first_child_id=existing_child_id,
second_child_id=incident_id,
org_id=org_id,
operational_type=incident_type,
location=cross_parent.get("location") or location,
location_coords=cross_parent.get("location_coords") or coords,
@@ -925,7 +940,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
else:
# Normal single-agency incident creation
incident_id = await _create_incident(
call_id, incident_type, talkgroup_id, talkgroup_name, system_id,
call_id, org_id, incident_type, talkgroup_id, talkgroup_name, system_id,
tags, location, location_coords,
call_units, call_vehicles, call_embedding, call_severity, now,
)
@@ -1346,6 +1361,7 @@ async def _update_incident(
async def _create_incident(
call_id: str,
org_id: Optional[str],
incident_type: str,
talkgroup_id: Optional[int],
talkgroup_name: Optional[str],
@@ -1377,6 +1393,7 @@ async def _create_incident(
doc = {
"incident_id": incident_id,
"org_id": org_id,
"title": title,
"incident_type": "master", # structural role; "child" set on demotion
"type": incident_type,
@@ -1424,6 +1441,7 @@ def _merge_embedding_vecs(inc: dict, call_embedding: list[float]) -> dict:
async def _create_master_incident(
first_child_id: str,
second_child_id: str,
org_id: Optional[str],
operational_type: str,
location: Optional[str],
location_coords: Optional[dict],
@@ -1437,6 +1455,7 @@ async def _create_master_incident(
master_id = str(uuid.uuid4())
doc = {
"incident_id": master_id,
"org_id": org_id,
"title": f"Multi-agency {operational_type} incident",
"incident_type": "master",
"type": operational_type,
+28 -1
View File
@@ -6,6 +6,7 @@ import paho.mqtt.client as mqtt
from app.config import settings
from app.internal.logger import logger
from app.internal import firestore as fstore
from app.internal.tenancy import FOUNDING_ORG_ID
class MQTTHandler:
@@ -87,9 +88,19 @@ class MQTTHandler:
now = datetime.now(timezone.utc)
if not existing:
# First time we've seen this node — create it as unconfigured, pending approval
# First time we've seen this node — create it as unconfigured, pending approval.
# This branch only fires for a node_id that has never gone through
# POST /nodes/enroll (routers/enrollment.py) — a properly-enrolled
# node already has a Firestore doc, with its real org_id, by the time
# its first checkin arrives, so `existing` would be truthy and this
# branch wouldn't run. What's left is the legacy shared-MQTT-password
# path (node-26 — see the TODO(mqtt-cutover) notes in this file),
# which has no enrollment token to resolve org_id from at all.
# Default it to FOUNDING_ORG_ID, same as enrollment.py's own
# legacy-token fallback.
doc = {
"node_id": node_id,
"org_id": FOUNDING_ORG_ID,
"name": payload.get("name", node_id),
"lat": payload.get("lat", 0.0),
"lon": payload.get("lon", 0.0),
@@ -194,6 +205,12 @@ class MQTTHandler:
# Look up assigned system for this node (cached — assignment rarely changes)
node = await fstore.doc_get_cached("nodes", node_id)
system_id = node.get("assigned_system_id") if node else None
# org_id is inherited from the node, not carried in the MQTT payload —
# this is the load-bearing tenancy stamp (SAAS_PLAN.md B2b): every call
# and, downstream, every incident correlated from it, traces back to
# this. None only for a call from a node that predates tenancy and
# hasn't been through scripts/backfill_org_id.py yet.
org_id = node.get("org_id") if node else None
started_at_raw = payload.get("started_at")
started_at = (
@@ -216,6 +233,7 @@ class MQTTHandler:
doc = {
"call_id": call_id,
"node_id": node_id,
"org_id": org_id,
"system_id": system_id,
"talkgroup_id": payload.get("tgid"),
"talkgroup_name": tgid_name,
@@ -249,6 +267,15 @@ class MQTTHandler:
"ended_at": ended_at,
"status": "ended",
}
# doc_set below is a merge, so if call_start already wrote org_id this
# is a no-op write of the same value. But DEFERRED.md notes call_end
# can in principle arrive before call_start (ordering relies on MQTT
# preserving per-topic order, which holds in practice but isn't
# guaranteed) — in that case doc_set would CREATE the calls doc here
# with no org_id at all unless it's resolved independently.
node = await fstore.doc_get_cached("nodes", node_id)
if node and node.get("org_id"):
updates["org_id"] = node["org_id"]
if payload.get("audio_url"):
updates["audio_url"] = payload["audio_url"]
+22
View File
@@ -0,0 +1,22 @@
"""
Shared tenancy constants used across auth, enrollment, org provisioning,
trip gating, and scripts/backfill_org_id.py.
FOUNDING_ORG_ID is the org every pre-tenancy document (nodes, systems,
calls, incidents, alert_rules created before this pass) gets stamped with by
scripts/backfill_org_id.py, and the org the legacy fleet-wide
settings.enrollment_token still resolves to in routers/enrollment.py so an
already-deployed field node doesn't break the day these rules deploy — see
that file's enroll_node() for the fallback path.
NOTE ON MODEL: per the owner's correction mid-build, DRB's access model is
participation-based (you run a node feeding the network, you get access to
the network's data), not per-seat SaaS — org_role is "owner"/"member" with
no tier axis, and organizations.plan_id/seat_limit/node_limit are inert
placeholders (see models.py OrganizationRecord) until a business-strategy
pass defines what, if anything, gates on them. FOUNDING_ORG_ID plays no
special role in that model beyond being the backfill target and the legacy
token's org — it is not a "free tier" or a privileged org in code.
"""
FOUNDING_ORG_ID = "founding"
+3 -1
View File
@@ -15,7 +15,7 @@ from app.internal.auth import (
require_node_service_or_firebase_token,
)
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
from app.routers import enrollment, media
from app.routers import enrollment, media, org, waitlist
from app.internal import dynsec
from app.internal import firestore as fstore
@@ -101,6 +101,8 @@ app.include_router(admin.router) # auth is per-endpoint (read: firebase, wri
app.include_router(users.router) # auth: admin only
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
app.include_router(enrollment.router) # public; auth is the enrollment/pickup-secret tokens, checked inline
app.include_router(org.router) # auth is per-endpoint (read: firebase, write: org owner)
app.include_router(waitlist.router) # public — no auth, source-IP rate limited inline
# public by necessity — an <audio src> can't send a bearer token, so the
# short-lived HMAC in the URL is the credential. Checked inline in media.py.
app.include_router(media.router)
+50
View File
@@ -3,6 +3,50 @@ from typing import Optional, List, Dict, Any
from datetime import datetime
# ---------------------------------------------------------------------------
# Organizations — the tenant boundary. See SAAS_PLAN.md B2 and
# app/internal/auth.py's require_org(). plan_id/subscription_status and the
# stripe_* fields are deliberately inert (None) here: no billing model has
# been decided yet (participation-based / reciprocal access, not per-seat
# SaaS — see the note on FOUNDING_ORG_ID in app/internal/tenancy.py), so this
# is just the seam a future billing pass would write into, not a promise
# about what that pass looks like.
# ---------------------------------------------------------------------------
class OrganizationRecord(BaseModel):
org_id: str
name: str
created_at: datetime
created_by_uid: str
plan_id: Optional[str] = None
subscription_status: Optional[str] = None
stripe_customer_id: Optional[str] = None
stripe_subscription_id: Optional[str] = None
current_period_end: Optional[datetime] = None
seat_limit: Optional[int] = None
node_limit: Optional[int] = None
retention_days: Optional[int] = None
class OrgMember(BaseModel):
uid: str
org_id: str
org_role: str # "owner" | "member"
email: Optional[str] = None
added_at: datetime
class EnrollmentTokenRecord(BaseModel):
"""Firestore doc id is the SHA-256 hash of the raw token — see
routers/enrollment.py's _hash_secret pattern (pickup_secret_hash)."""
org_id: str
label: str
created_at: datetime
created_by_uid: str
revoked: bool = False
uses: int = 0
# ---------------------------------------------------------------------------
# Nodes
# ---------------------------------------------------------------------------
@@ -10,6 +54,7 @@ from datetime import datetime
class NodeRecord(BaseModel):
node_id: str
name: str
org_id: Optional[str] = None # stamped at enrollment; None only on pre-tenancy docs awaiting backfill
lat: float = 0.0
lon: float = 0.0
status: str = "offline" # online / offline / recording / unconfigured
@@ -35,6 +80,7 @@ class CommandPayload(BaseModel):
class SystemRecord(BaseModel):
system_id: str
org_id: Optional[str] = None
name: str
type: str # P25 / DMR / NBFM
config: Dict[str, Any] = {} # OP25-compatible config blob
@@ -55,6 +101,7 @@ class SystemCreate(BaseModel):
class CallRecord(BaseModel):
call_id: str
node_id: str
org_id: Optional[str] = None # inherited from the node at call_start/upload — see internal/mqtt_handler.py
system_id: Optional[str] = None
talkgroup_id: Optional[int] = None
talkgroup_name: Optional[str] = None
@@ -78,6 +125,7 @@ class CallRecord(BaseModel):
class IncidentRecord(BaseModel):
incident_id: str
org_id: Optional[str] = None # inherited from the calls that created it — see internal/incident_correlator.py
title: Optional[str] = None
type: Optional[str] = None # fire / police / ems / etc.
status: str = "active" # active / resolved
@@ -114,6 +162,7 @@ class IncidentUpdate(BaseModel):
class AlertRule(BaseModel):
rule_id: Optional[str] = None
org_id: Optional[str] = None
name: str
keywords: List[str] = []
talkgroup_ids: List[int] = []
@@ -131,6 +180,7 @@ class AlertRuleUpdate(BaseModel):
class AlertEvent(BaseModel):
alert_id: Optional[str] = None
org_id: Optional[str] = None
rule_id: str
rule_name: str
call_id: str
+8 -3
View File
@@ -1,7 +1,7 @@
import asyncio
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, Query
from app.internal.auth import require_admin_token, require_firebase_token
from app.internal.auth import require_admin_token
from app.internal.feature_flags import get_flags, set_flags
from app.internal import firestore as fstore
@@ -24,8 +24,13 @@ router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/features")
async def get_feature_flags(_=Depends(require_firebase_token)):
"""Return the current AI feature flag state. Any authenticated user can read."""
async def get_feature_flags(_=Depends(require_admin_token)):
"""
Return the current AI feature flag state. Admin-only (SAAS_PLAN.md B2c) —
was previously any authenticated user via require_firebase_token, which
handed platform-wide AI configuration state to every signed-in viewer
regardless of org.
"""
return await get_flags()
+28 -6
View File
@@ -1,10 +1,11 @@
import uuid
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends
from fastapi import APIRouter, HTTPException, Depends, Query
from app.models import AlertRule, AlertRuleUpdate
from app.internal import firestore as fstore
from app.internal.auth import require_admin_token
from app.internal.auth import require_admin_token, require_service_or_firebase_token, resolve_caller_org_id
from app.internal.tenancy import FOUNDING_ORG_ID
router = APIRouter(tags=["alerts"])
@@ -14,18 +15,31 @@ router = APIRouter(tags=["alerts"])
# ---------------------------------------------------------------------------
@router.get("/alerts")
async def list_alerts(acknowledged: Optional[bool] = None):
async def list_alerts(
acknowledged: Optional[bool] = None,
decoded: dict = Depends(require_service_or_firebase_token),
):
filters = {}
if acknowledged is not None:
filters["acknowledged"] = acknowledged
org_id = await resolve_caller_org_id(decoded)
if org_id is not None:
filters["org_id"] = org_id
return await fstore.collection_list("alert_events", **filters)
@router.post("/alerts/{alert_id}/acknowledge")
async def acknowledge_alert(alert_id: str):
async def acknowledge_alert(alert_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
doc = await fstore.doc_get("alert_events", alert_id)
if not doc:
raise HTTPException(404, f"Alert '{alert_id}' not found.")
# SAAS_PLAN.md B2c: previously any authenticated viewer could acknowledge
# any org's alerts. org_id may be absent on a pre-tenancy alert_event
# that hasn't been through scripts/backfill_org_id.py yet — allow those
# through rather than making them permanently unacknowledgeable.
org_id = await resolve_caller_org_id(decoded)
if org_id is not None and doc.get("org_id") and doc.get("org_id") != org_id:
raise HTTPException(404, f"Alert '{alert_id}' not found.")
await fstore.doc_update("alert_events", alert_id, {"acknowledged": True})
return {"ok": True}
@@ -35,15 +49,23 @@ async def acknowledge_alert(alert_id: str):
# ---------------------------------------------------------------------------
@router.get("/alert-rules")
async def list_alert_rules():
async def list_alert_rules(decoded: dict = Depends(require_service_or_firebase_token)):
org_id = await resolve_caller_org_id(decoded)
if org_id is not None:
return await fstore.collection_list("alert_rules", org_id=org_id)
return await fstore.collection_list("alert_rules")
@router.post("/alert-rules")
async def create_alert_rule(body: AlertRule, _: dict = Depends(require_admin_token)):
async def create_alert_rule(
body: AlertRule,
org_id: Optional[str] = Query(None, description="Platform-admin only — defaults to the founding org."),
_: dict = Depends(require_admin_token),
):
rule_id = str(uuid.uuid4())
doc = {
"rule_id": rule_id,
"org_id": org_id or FOUNDING_ORG_ID,
"name": body.name,
"keywords": body.keywords,
"talkgroup_ids": body.talkgroup_ids,
+29 -4
View File
@@ -3,7 +3,12 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends
from pydantic import BaseModel
from typing import Optional
from app.internal import firestore as fstore
from app.internal.auth import require_admin_token
from app.internal.auth import (
require_admin_token,
require_service_or_firebase_token,
resolve_caller_org_id,
reprocess_limiter,
)
from app.internal.storage import gcs_uri_for_call, with_playback_url
@@ -18,6 +23,7 @@ async def list_calls(
node_id: Optional[str] = Query(None),
status: Optional[str] = Query(None),
system_id: Optional[str] = Query(None),
decoded: dict = Depends(require_service_or_firebase_token),
):
filters = {}
if node_id:
@@ -26,25 +32,44 @@ async def list_calls(
filters["status"] = status
if system_id:
filters["system_id"] = system_id
org_id = await resolve_caller_org_id(decoded)
if org_id is not None: # service key / platform admin stay unrestricted
filters["org_id"] = org_id
calls = await fstore.collection_list("calls", **filters)
# audio_url is not stored — it's a short-lived signed link minted per read.
return [with_playback_url(c) for c in calls]
@router.get("/{call_id}")
async def get_call(call_id: str):
async def get_call(call_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
call = await fstore.doc_get("calls", call_id)
if not call:
raise HTTPException(404, f"Call '{call_id}' not found.")
org_id = await resolve_caller_org_id(decoded)
if org_id is not None and call.get("org_id") != org_id:
raise HTTPException(404, f"Call '{call_id}' not found.")
return with_playback_url(call)
@router.post("/{call_id}/reprocess")
async def reprocess_call(call_id: str, background_tasks: BackgroundTasks):
"""Re-run the full intelligence pipeline (transcription → extraction → correlation) for a call."""
async def reprocess_call(
call_id: str,
background_tasks: BackgroundTasks,
_: dict = Depends(require_admin_token),
):
"""
Re-run the full intelligence pipeline (transcription -> extraction ->
correlation) for a call. Admin-only (SAAS_PLAN.md B2c) — this was
previously gated only by "any valid Firebase token", which meant any
signed-in viewer could loop it and burn the owner's OpenAI/Gemini
credits (DEFERRED.md, calls.py:42). The rate limiter below is a second
guard against the same thing happening from a compromised/careless
admin session, not the primary fix.
"""
call = await fstore.doc_get("calls", call_id)
if not call:
raise HTTPException(404, f"Call '{call_id}' not found.")
reprocess_limiter.check(call_id)
from app.routers.upload import _run_intelligence_pipeline
+38 -4
View File
@@ -25,6 +25,7 @@ from pydantic import BaseModel
from app.config import settings
from app.internal import firestore as fstore
from app.internal.logger import logger
from app.internal.tenancy import FOUNDING_ORG_ID
router = APIRouter(prefix="/nodes", tags=["enrollment"])
@@ -69,6 +70,31 @@ def _hash_secret(secret: str) -> str:
return hashlib.sha256(secret.encode()).hexdigest()
async def _resolve_org_for_token(token: str) -> Optional[str]:
"""
Resolve an X-Enrollment-Token to the org_id it enrolls a node into.
Tries the per-org enrollment_tokens collection first (SAAS_PLAN.md B2b —
minted/listed/revoked via routers/org.py), then falls back to the legacy
fleet-wide settings.enrollment_token so an already-deployed field node's
.env doesn't start failing the day per-org tokens ship. The fallback
always resolves to FOUNDING_ORG_ID — see app/internal/tenancy.py.
"""
token_hash = _hash_secret(token)
doc = await fstore.doc_get("enrollment_tokens", token_hash)
if doc and not doc.get("revoked"):
try:
await fstore.doc_update("enrollment_tokens", token_hash, {"uses": (doc.get("uses") or 0) + 1})
except Exception:
pass # use-counter is informational only — never block enrollment on it
return doc.get("org_id")
if settings.enrollment_token and secrets.compare_digest(token, settings.enrollment_token):
return FOUNDING_ORG_ID
return None
class EnrollRequest(BaseModel):
node_id: str
name: Optional[str] = None
@@ -92,10 +118,13 @@ async def enroll_node(
if not _enroll_limiter.allow(client_ip):
raise HTTPException(429, "Too many enrollment attempts. Try again later.")
if not settings.enrollment_token:
raise HTTPException(503, "Enrollment is not configured on this server.")
if not x_enrollment_token or not secrets.compare_digest(x_enrollment_token, settings.enrollment_token):
logger.warning(f"Enroll 401: bad/missing enrollment token from {client_ip} for node_id={body.node_id!r}")
if not x_enrollment_token:
logger.warning(f"Enroll 401: missing X-Enrollment-Token from {client_ip} for node_id={body.node_id!r}")
raise HTTPException(401, "Invalid or missing X-Enrollment-Token")
org_id = await _resolve_org_for_token(x_enrollment_token)
if not org_id:
logger.warning(f"Enroll 401: bad enrollment token from {client_ip} for node_id={body.node_id!r}")
raise HTTPException(401, "Invalid or missing X-Enrollment-Token")
node_id = body.node_id.strip()
@@ -136,6 +165,11 @@ async def enroll_node(
pickup_secret = secrets.token_hex(24)
doc = {
"node_id": node_id,
# A node re-enrolling keeps whatever org_id it already has rather
# than letting a differently-scoped token silently reassign its
# tenancy — only a brand-new node_id (or one that predates tenancy
# entirely) picks up org_id from the token used here.
"org_id": (existing or {}).get("org_id") or org_id,
"name": body.name or (existing or {}).get("name") or node_id,
"lat": body.lat or (existing or {}).get("lat", 0.0),
"lon": body.lon or (existing or {}).get("lon", 0.0),
+18 -3
View File
@@ -4,18 +4,30 @@ from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
from app.models import IncidentCreate, IncidentUpdate
from app.internal import firestore as fstore
from app.internal.auth import require_admin_token, require_service_or_firebase_token, summarize_limiter
from app.internal.auth import (
require_admin_token,
require_service_or_firebase_token,
resolve_caller_org_id,
summarize_limiter,
)
router = APIRouter(prefix="/incidents", tags=["incidents"])
@router.get("")
async def list_incidents(status: Optional[str] = None, type: Optional[str] = None):
async def list_incidents(
status: Optional[str] = None,
type: Optional[str] = None,
decoded: dict = Depends(require_service_or_firebase_token),
):
filters = {}
if status:
filters["status"] = status
if type:
filters["type"] = type
org_id = await resolve_caller_org_id(decoded)
if org_id is not None:
filters["org_id"] = org_id
return await fstore.collection_list("incidents", **filters)
@@ -31,10 +43,13 @@ async def summarize_all_stale(
@router.get("/{incident_id}")
async def get_incident(incident_id: str):
async def get_incident(incident_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
doc = await fstore.doc_get("incidents", incident_id)
if not doc:
raise HTTPException(404, f"Incident '{incident_id}' not found.")
org_id = await resolve_caller_org_id(decoded)
if org_id is not None and doc.get("org_id") != org_id:
raise HTTPException(404, f"Incident '{incident_id}' not found.")
return doc
+88 -1
View File
@@ -1,11 +1,13 @@
import asyncio
import random
import string
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Depends, Request
from firebase_admin import auth as firebase_auth
from pydantic import BaseModel
from app.internal import firestore as fstore
from app.internal.auth import require_firebase_token, require_service_key
from app.internal.auth import require_firebase_token, require_service_key, get_role
from app.internal.logger import logger
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -129,6 +131,91 @@ async def unlink(decoded: dict = Depends(require_firebase_token)):
return {"ok": True}
# ---------------------------------------------------------------------------
# Org provisioning — SAAS_PLAN.md B4. The client creates the Firebase user
# first (email/password or Google) and calls this with that user's fresh ID
# token, which carries no org_id/org_role claim yet. This is the only route
# that turns "has a Firebase account" into "can read anything" — see
# infra/firestore/firestore.rules and AuthProvider's no-claim guard.
# ---------------------------------------------------------------------------
class SignupBody(BaseModel):
org_name: str
@router.post("/signup")
async def signup(body: SignupBody, decoded: dict = Depends(require_firebase_token)):
"""
Provision a new organization owned by the calling user, or return their
existing one. Idempotent by design: the frontend calls this right after
account creation, and a user who double-submits (or re-runs it after a
refresh) must not end up with two orgs.
"""
uid = decoded["uid"]
existing_org_id = decoded.get("org_id")
if existing_org_id:
org = await fstore.doc_get("organizations", existing_org_id)
if org:
return {"org_id": existing_org_id, "org_name": org.get("name"), "already_provisioned": True}
# Claim points at a deleted/missing org doc — fall through and
# provision a fresh one rather than leaving the account stranded.
org_name = body.org_name.strip()
if not org_name:
raise HTTPException(400, "org_name is required.")
if len(org_name) > 200:
raise HTTPException(400, "org_name is too long.")
org_id = str(uuid4())
now = datetime.now(timezone.utc).isoformat()
# plan_id/subscription_status/stripe_*/seat_limit/node_limit/retention_days
# are all deliberately None — no billing model exists yet (see
# app/internal/tenancy.py). This is the seam a future billing pass writes
# into; nothing today reads or enforces these fields.
await fstore.doc_set("organizations", org_id, {
"org_id": org_id,
"name": org_name,
"created_at": now,
"created_by_uid": uid,
"plan_id": None,
"subscription_status": None,
"stripe_customer_id": None,
"stripe_subscription_id": None,
"current_period_end": None,
"seat_limit": None,
"node_limit": None,
"retention_days": None,
}, merge=False)
await fstore.doc_set("org_members", uid, {
"uid": uid,
"org_id": org_id,
"org_role": "owner",
"email": decoded.get("email"),
"added_at": now,
}, merge=False)
# set_custom_user_claims() replaces the whole claim set, so preserve any
# existing custom claims (owned_node_ids, a platform `role` if this
# account was created via the admin-only POST /admin/users flow, etc.)
# rather than clobbering them. Firebase's own reserved JWT fields are
# stripped out — they aren't settable as custom claims and would raise.
_RESERVED = {
"iss", "aud", "auth_time", "user_id", "sub", "iat", "exp", "uid",
"email", "email_verified", "firebase", "name", "picture",
}
existing_claims = {k: v for k, v in decoded.items() if k not in _RESERVED}
# role: platform-level, orthogonal to org ownership. get_role() falls
# back to "viewer" for a brand-new self-serve signup with no claims yet.
claims = {**existing_claims, "org_id": org_id, "org_role": "owner", "role": get_role(decoded)}
await asyncio.to_thread(firebase_auth.set_custom_user_claims, uid, claims)
logger.info(f"Org provisioned: org_id={org_id} name={org_name!r} owner_uid={uid}")
return {"org_id": org_id, "org_name": org_name, "already_provisioned": False}
# ---------------------------------------------------------------------------
# Session recording — called by the frontend on each successful sign-in
# ---------------------------------------------------------------------------
+15 -4
View File
@@ -7,22 +7,33 @@ from app.internal import firestore as fstore
from app.internal.mqtt_handler import mqtt_handler
from app.internal import dynsec
from app.internal.logger import logger
from app.internal.auth import require_admin_token, require_service_key_or_admin
from app.internal.auth import (
require_admin_token,
require_service_key_or_admin,
require_service_or_firebase_token,
resolve_caller_org_id,
)
from app.routers.tokens import assign_token, release_token
router = APIRouter(prefix="/nodes", tags=["nodes"])
@router.get("")
async def list_nodes():
return await fstore.collection_list("nodes")
async def list_nodes(decoded: dict = Depends(require_service_or_firebase_token)):
org_id = await resolve_caller_org_id(decoded)
if org_id is None: # service key or platform admin — unrestricted, matches prior behaviour
return await fstore.collection_list("nodes")
return await fstore.collection_list("nodes", org_id=org_id)
@router.get("/{node_id}")
async def get_node(node_id: str):
async def get_node(node_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
node = await fstore.doc_get("nodes", node_id)
if not node:
raise HTTPException(404, f"Node '{node_id}' not found.")
org_id = await resolve_caller_org_id(decoded)
if org_id is not None and node.get("org_id") != org_id:
raise HTTPException(404, f"Node '{node_id}' not found.")
return node
+121
View File
@@ -0,0 +1,121 @@
"""
Organization-scoped routes.
Two things live here:
1. Org profile (name) — closes the "Save changes" button that's been
disabled in app/settings/organization since there was no organizations
concept server-side to save into (see DEFERRED.md, now resolved).
2. Per-org enrollment tokens (SAAS_PLAN.md B2b) — the credential that lets
a customer's own node join THEIR org specifically. Before this, every
node enrolled with the same fleet-wide ENROLLMENT_TOKEN
(routers/enrollment.py), which had no way to say which org a newly
enrolled node belonged to — every node landed in the same pool.
"""
import hashlib
import secrets
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from app.internal import firestore as fstore
from app.internal.auth import require_firebase_token, require_org, require_org_owner_token
from app.internal.logger import logger
router = APIRouter(prefix="/org", tags=["org"])
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
# ---------------------------------------------------------------------------
# Org profile
# ---------------------------------------------------------------------------
@router.get("")
async def get_org(decoded: dict = Depends(require_firebase_token)):
"""Any member of the org (owner or member) can read the org profile."""
org_id = require_org(decoded)
org = await fstore.doc_get("organizations", org_id)
if not org:
raise HTTPException(404, "Organization not found.")
return org
class OrgUpdateBody(BaseModel):
name: str
@router.patch("")
async def update_org(body: OrgUpdateBody, decoded: dict = Depends(require_org_owner_token)):
org_id = require_org(decoded)
name = body.name.strip()
if not name:
raise HTTPException(400, "name must not be empty.")
await fstore.doc_update("organizations", org_id, {"name": name})
return {"ok": True, "name": name}
# ---------------------------------------------------------------------------
# Enrollment tokens — mint/list/revoke. Minting and revoking are owner-only
# (this is fleet-security-sensitive, same tier as node approval); any org
# member can list them (metadata only, never the raw value) since anyone on
# the team might be the one physically standing up the next node.
# ---------------------------------------------------------------------------
class MintTokenBody(BaseModel):
label: str
class MintTokenResponse(BaseModel):
token_id: str
token: str # raw value — returned exactly once, never again, never stored
label: str
@router.post("/enrollment-tokens", response_model=MintTokenResponse)
async def mint_enrollment_token(body: MintTokenBody, decoded: dict = Depends(require_org_owner_token)):
org_id = require_org(decoded)
label = body.label.strip() or "Unnamed token"
raw = secrets.token_hex(24)
token_hash = _hash_token(raw)
now = datetime.now(timezone.utc).isoformat()
# Doc id IS the hash (matches enrollment.py's pickup_secret_hash pattern) —
# also stored as a field so list/delete below don't need a second lookup.
await fstore.doc_set("enrollment_tokens", token_hash, {
"token_hash": token_hash,
"org_id": org_id,
"label": label,
"created_at": now,
"created_by_uid": decoded.get("uid"),
"revoked": False,
"uses": 0,
}, merge=False)
logger.info(f"Enrollment token minted for org={org_id!r} label={label!r} by uid={decoded.get('uid')}")
return MintTokenResponse(token_id=token_hash, token=raw, label=label)
@router.get("/enrollment-tokens")
async def list_enrollment_tokens(decoded: dict = Depends(require_firebase_token)):
org_id = require_org(decoded)
tokens = await fstore.collection_list("enrollment_tokens", org_id=org_id)
return [
{
"token_id": t.get("token_hash"),
"label": t.get("label"),
"created_at": t.get("created_at"),
"revoked": t.get("revoked", False),
"uses": t.get("uses", 0),
}
for t in tokens
]
@router.delete("/enrollment-tokens/{token_id}")
async def revoke_enrollment_token(token_id: str, decoded: dict = Depends(require_org_owner_token)):
org_id = require_org(decoded)
doc = await fstore.doc_get("enrollment_tokens", token_id)
if not doc or doc.get("org_id") != org_id:
raise HTTPException(404, "Enrollment token not found.")
await fstore.doc_update("enrollment_tokens", token_id, {"revoked": True})
logger.info(f"Enrollment token revoked: org={org_id!r} token_id={token_id}")
return {"ok": True}
+23 -7
View File
@@ -1,10 +1,16 @@
import uuid
from fastapi import APIRouter, HTTPException, Depends
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from typing import Dict, Optional
from app.models import SystemCreate, SystemRecord
from app.internal import firestore as fstore
from app.internal.auth import require_admin_token, bootstrap_limiter
from app.internal.auth import (
require_admin_token,
require_node_service_or_firebase_token,
resolve_caller_org_id,
bootstrap_limiter,
)
from app.internal.tenancy import FOUNDING_ORG_ID
router = APIRouter(prefix="/systems", tags=["systems"])
@@ -23,22 +29,32 @@ class AiFlagsBody(BaseModel):
@router.get("")
async def list_systems():
return await fstore.collection_list("systems")
async def list_systems(decoded: dict = Depends(require_node_service_or_firebase_token)):
org_id = await resolve_caller_org_id(decoded)
if org_id is None: # service key or platform admin — unrestricted, matches prior behaviour
return await fstore.collection_list("systems")
return await fstore.collection_list("systems", org_id=org_id)
@router.get("/{system_id}")
async def get_system(system_id: str):
async def get_system(system_id: str, decoded: dict = Depends(require_node_service_or_firebase_token)):
system = await fstore.doc_get("systems", system_id)
if not system:
raise HTTPException(404, f"System '{system_id}' not found.")
org_id = await resolve_caller_org_id(decoded)
if org_id is not None and system.get("org_id") != org_id:
raise HTTPException(404, f"System '{system_id}' not found.")
return system
@router.post("", status_code=201)
async def create_system(body: SystemCreate, _: dict = Depends(require_admin_token)):
async def create_system(
body: SystemCreate,
org_id: Optional[str] = Query(None, description="Platform-admin only — defaults to the founding org."),
_: dict = Depends(require_admin_token),
):
system_id = str(uuid.uuid4())
doc = SystemRecord(system_id=system_id, **body.model_dump())
doc = SystemRecord(system_id=system_id, org_id=org_id or FOUNDING_ORG_ID, **body.model_dump())
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
return doc
+26 -4
View File
@@ -13,8 +13,10 @@ from app.internal.auth import (
require_service_or_firebase_token,
require_service_key,
require_service_key_or_admin,
get_role,
trip_chat_limiter,
)
from app.internal.tenancy import FOUNDING_ORG_ID
router = APIRouter(prefix="/trips", tags=["trips"])
@@ -23,6 +25,22 @@ router = APIRouter(prefix="/trips", tags=["trips"])
# Access control helpers
# ---------------------------------------------------------------------------
def _require_founding_org(decoded: dict) -> None:
"""
Trips is an internal utility feature riding along on this stack, not a
tenant-scoped product surface (see [[trips-feature-intentional]] and
SAAS_PLAN.md B7/B2c) — it has no org_id on its documents and isn't
getting one in this pass. Restricting mutations to the founding org (plus
the bot's service key, and platform admins for support) is how it stays
usable for its original purpose without becoming a write surface every
new customer org can reach into.
"""
if decoded.get("service") or get_role(decoded) == "admin":
return
if decoded.get("org_id") != FOUNDING_ORG_ID:
raise HTTPException(403, "Trip planning is available to the founding org only.")
async def _discord_id_for_firebase(firebase_uid: str) -> Optional[str]:
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
return (link or {}).get("discord_user_id")
@@ -224,7 +242,8 @@ async def list_trips(decoded: dict = Depends(require_service_or_firebase_token))
@router.post("")
async def create_trip(body: TripCreate):
async def create_trip(body: TripCreate, decoded: dict = Depends(require_service_or_firebase_token)):
_require_founding_org(decoded)
if body.end_date < body.start_date:
raise HTTPException(400, "end_date must be on or after start_date.")
trip_id = str(uuid.uuid4())
@@ -263,8 +282,9 @@ async def get_trip(trip_id: str, decoded: dict = Depends(require_service_or_fire
@router.put("/{trip_id}/tags")
async def update_trip_tags(trip_id: str, body: dict):
async def update_trip_tags(trip_id: str, body: dict, decoded: dict = Depends(require_service_or_firebase_token)):
"""Replace the trip's available tag list and overlap-allowed tag list."""
_require_founding_org(decoded)
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
@@ -363,7 +383,8 @@ async def leave_trip(
@router.post("/{trip_id}/events")
async def create_event(trip_id: str, body: TripEventCreate):
async def create_event(trip_id: str, body: TripEventCreate, decoded: dict = Depends(require_service_or_firebase_token)):
_require_founding_org(decoded)
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
@@ -396,7 +417,8 @@ async def create_event(trip_id: str, body: TripEventCreate):
@router.patch("/{trip_id}/events/{event_id}")
async def update_event(trip_id: str, event_id: str, body: TripEventUpdate):
async def update_event(trip_id: str, event_id: str, body: TripEventUpdate, decoded: dict = Depends(require_service_or_firebase_token)):
_require_founding_org(decoded)
event = await fstore.doc_get("trip_events", event_id)
if not event or event.get("trip_id") != trip_id:
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
+11 -1
View File
@@ -54,7 +54,17 @@ async def upload_call_audio(
try:
# Canonical object location only. The playback link is minted per
# read in storage.playback_url() — nothing durable is stored here.
await fstore.doc_set("calls", call_id, {"audio_gcs_uri": gcs_uri})
# org_id is stamped defensively here too (not just in
# mqtt_handler.py's call_start/call_end): key_doc above proves this
# node_id is real and authenticated, so resolving org_id from the
# node doc here covers a call whose Firestore doc was somehow
# never written by call_start (the upload is otherwise the
# authoritative record of which node this audio came from).
node = await fstore.doc_get_cached("nodes", node_id)
updates = {"audio_gcs_uri": gcs_uri}
if node and node.get("org_id"):
updates["org_id"] = node["org_id"]
await fstore.doc_set("calls", call_id, updates)
except Exception as e:
logger.warning(f"Could not update call {call_id} with audio_gcs_uri: {e}")
+57
View File
@@ -0,0 +1,57 @@
"""
Public waitlist submission — no self-serve org creation is promised here,
just "we'll get back to you". Not coupled to any plan/tier: the commercial
model (participation-based access, not per-seat SaaS — see
app/internal/tenancy.py) is still being defined separately, so this route
only ever writes {email, org_name, note} and never a plan_id.
Unauthenticated by design (a prospect has no account yet), so the only
protection against abuse is source-IP rate limiting via the shared
_RateLimiter (app/internal/auth.py).
"""
from datetime import datetime, timezone
from typing import Optional
from uuid import uuid4
from fastapi import APIRouter, Request
from pydantic import BaseModel, field_validator
from app.internal import firestore as fstore
from app.internal.auth import waitlist_limiter
from app.internal.logger import logger
router = APIRouter(tags=["waitlist"])
class WaitlistBody(BaseModel):
# Plain str, not pydantic.EmailStr — EmailStr needs the email-validator
# package, which isn't in requirements.txt, and adding a dependency for
# one light check wasn't worth it. Good-enough sanity check only; this
# is a marketing capture form, not an auth path.
email: str
org_name: Optional[str] = None
note: Optional[str] = None
@field_validator("email")
@classmethod
def _basic_email_shape(cls, v: str) -> str:
v = v.strip()
if "@" not in v or " " in v or len(v) > 254:
raise ValueError("Enter a valid email address.")
return v
@router.post("/waitlist", status_code=201)
async def join_waitlist(body: WaitlistBody, request: Request):
client_ip = request.client.host if request.client else "unknown"
waitlist_limiter.check(client_ip)
entry_id = str(uuid4())
await fstore.doc_set("waitlist", entry_id, {
"entry_id": entry_id,
"email": body.email.lower(),
"org_name": (body.org_name or "").strip() or None,
"note": (body.note or "").strip()[:2000] or None,
"created_at": datetime.now(timezone.utc).isoformat(),
"source_ip": client_ip,
}, merge=False)
logger.info(f"Waitlist signup: {body.email!r} (org_name={body.org_name!r})")
return {"ok": True}