Stamp org_id everywhere and gate every route that leaked across tenants
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:
co-authored by
Claude Opus 5
parent
74faa55396
commit
a3681ea698
@@ -27,6 +27,21 @@ async def check_and_dispatch(
|
|||||||
Check all enabled alert rules and fire events for any that match this call.
|
Check all enabled alert rules and fire events for any that match this call.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# 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)
|
rules = await fstore.collection_list("alert_rules", enabled=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Alerter: could not load rules: {e}")
|
logger.warning(f"Alerter: could not load rules: {e}")
|
||||||
@@ -42,6 +57,7 @@ async def check_and_dispatch(
|
|||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
event = {
|
event = {
|
||||||
"alert_id": alert_id,
|
"alert_id": alert_id,
|
||||||
|
"org_id": org_id,
|
||||||
"rule_id": rule.get("rule_id", ""),
|
"rule_id": rule.get("rule_id", ""),
|
||||||
"rule_name": rule.get("name", ""),
|
"rule_name": rule.get("name", ""),
|
||||||
"call_id": call_id,
|
"call_id": call_id,
|
||||||
|
|||||||
@@ -84,6 +84,93 @@ def get_role(decoded: dict) -> str:
|
|||||||
return role if role in ("admin", "operator", "viewer") else "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(
|
async def require_admin_token(
|
||||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -165,3 +252,13 @@ trip_chat_limiter = _RateLimiter(max_calls=20, window_seconds=300)
|
|||||||
summarize_limiter = _RateLimiter(max_calls=5, window_seconds=600)
|
summarize_limiter = _RateLimiter(max_calls=5, window_seconds=600)
|
||||||
# vocabulary bootstrap: 2 per system per hour
|
# vocabulary bootstrap: 2 per system per hour
|
||||||
bootstrap_limiter = _RateLimiter(max_calls=2, window_seconds=3600)
|
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)
|
now = reference_time or datetime.now(timezone.utc)
|
||||||
window = timedelta(hours=settings.correlation_window_hours)
|
window = timedelta(hours=settings.correlation_window_hours)
|
||||||
|
|
||||||
|
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")
|
all_active = await fstore.collection_list("incidents", status="active")
|
||||||
recent = [inc for inc in all_active if _within_window_of(inc, now, window)]
|
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_embedding = call_doc.get("embedding")
|
||||||
call_units = units if units is not None else (call_doc.get("units") or [])
|
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 [])
|
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
|
is_thin_call = not call_units and not call_vehicles and not coords
|
||||||
|
|
||||||
return {
|
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_doc": call_doc, "call_embedding": call_embedding,
|
||||||
"call_units": call_units, "call_vehicles": call_vehicles,
|
"call_units": call_units, "call_vehicles": call_vehicles,
|
||||||
"call_cleared": call_cleared, "call_severity": call_severity,
|
"call_cleared": call_cleared, "call_severity": call_severity,
|
||||||
@@ -839,6 +852,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
call_id = ctx["call_id"]
|
call_id = ctx["call_id"]
|
||||||
|
org_id = ctx["org_id"]
|
||||||
talkgroup_id = ctx["talkgroup_id"]
|
talkgroup_id = ctx["talkgroup_id"]
|
||||||
talkgroup_name = ctx["talkgroup_name"]
|
talkgroup_name = ctx["talkgroup_name"]
|
||||||
system_id = ctx["system_id"]
|
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
|
# Create the new agency's child incident first
|
||||||
incident_id = await _create_incident(
|
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,
|
tags, location, location_coords,
|
||||||
call_units, call_vehicles, call_embedding, call_severity, now,
|
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(
|
master_id = await _create_master_incident(
|
||||||
first_child_id=existing_child_id,
|
first_child_id=existing_child_id,
|
||||||
second_child_id=incident_id,
|
second_child_id=incident_id,
|
||||||
|
org_id=org_id,
|
||||||
operational_type=incident_type,
|
operational_type=incident_type,
|
||||||
location=cross_parent.get("location") or location,
|
location=cross_parent.get("location") or location,
|
||||||
location_coords=cross_parent.get("location_coords") or coords,
|
location_coords=cross_parent.get("location_coords") or coords,
|
||||||
@@ -925,7 +940,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
|
|||||||
else:
|
else:
|
||||||
# Normal single-agency incident creation
|
# Normal single-agency incident creation
|
||||||
incident_id = await _create_incident(
|
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,
|
tags, location, location_coords,
|
||||||
call_units, call_vehicles, call_embedding, call_severity, now,
|
call_units, call_vehicles, call_embedding, call_severity, now,
|
||||||
)
|
)
|
||||||
@@ -1346,6 +1361,7 @@ async def _update_incident(
|
|||||||
|
|
||||||
async def _create_incident(
|
async def _create_incident(
|
||||||
call_id: str,
|
call_id: str,
|
||||||
|
org_id: Optional[str],
|
||||||
incident_type: str,
|
incident_type: str,
|
||||||
talkgroup_id: Optional[int],
|
talkgroup_id: Optional[int],
|
||||||
talkgroup_name: Optional[str],
|
talkgroup_name: Optional[str],
|
||||||
@@ -1377,6 +1393,7 @@ async def _create_incident(
|
|||||||
|
|
||||||
doc = {
|
doc = {
|
||||||
"incident_id": incident_id,
|
"incident_id": incident_id,
|
||||||
|
"org_id": org_id,
|
||||||
"title": title,
|
"title": title,
|
||||||
"incident_type": "master", # structural role; "child" set on demotion
|
"incident_type": "master", # structural role; "child" set on demotion
|
||||||
"type": incident_type,
|
"type": incident_type,
|
||||||
@@ -1424,6 +1441,7 @@ def _merge_embedding_vecs(inc: dict, call_embedding: list[float]) -> dict:
|
|||||||
async def _create_master_incident(
|
async def _create_master_incident(
|
||||||
first_child_id: str,
|
first_child_id: str,
|
||||||
second_child_id: str,
|
second_child_id: str,
|
||||||
|
org_id: Optional[str],
|
||||||
operational_type: str,
|
operational_type: str,
|
||||||
location: Optional[str],
|
location: Optional[str],
|
||||||
location_coords: Optional[dict],
|
location_coords: Optional[dict],
|
||||||
@@ -1437,6 +1455,7 @@ async def _create_master_incident(
|
|||||||
master_id = str(uuid.uuid4())
|
master_id = str(uuid.uuid4())
|
||||||
doc = {
|
doc = {
|
||||||
"incident_id": master_id,
|
"incident_id": master_id,
|
||||||
|
"org_id": org_id,
|
||||||
"title": f"Multi-agency {operational_type} incident",
|
"title": f"Multi-agency {operational_type} incident",
|
||||||
"incident_type": "master",
|
"incident_type": "master",
|
||||||
"type": operational_type,
|
"type": operational_type,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import paho.mqtt.client as mqtt
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.tenancy import FOUNDING_ORG_ID
|
||||||
|
|
||||||
|
|
||||||
class MQTTHandler:
|
class MQTTHandler:
|
||||||
@@ -87,9 +88,19 @@ class MQTTHandler:
|
|||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
if not existing:
|
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 = {
|
doc = {
|
||||||
"node_id": node_id,
|
"node_id": node_id,
|
||||||
|
"org_id": FOUNDING_ORG_ID,
|
||||||
"name": payload.get("name", node_id),
|
"name": payload.get("name", node_id),
|
||||||
"lat": payload.get("lat", 0.0),
|
"lat": payload.get("lat", 0.0),
|
||||||
"lon": payload.get("lon", 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)
|
# Look up assigned system for this node (cached — assignment rarely changes)
|
||||||
node = await fstore.doc_get_cached("nodes", node_id)
|
node = await fstore.doc_get_cached("nodes", node_id)
|
||||||
system_id = node.get("assigned_system_id") if node else None
|
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_raw = payload.get("started_at")
|
||||||
started_at = (
|
started_at = (
|
||||||
@@ -216,6 +233,7 @@ class MQTTHandler:
|
|||||||
doc = {
|
doc = {
|
||||||
"call_id": call_id,
|
"call_id": call_id,
|
||||||
"node_id": node_id,
|
"node_id": node_id,
|
||||||
|
"org_id": org_id,
|
||||||
"system_id": system_id,
|
"system_id": system_id,
|
||||||
"talkgroup_id": payload.get("tgid"),
|
"talkgroup_id": payload.get("tgid"),
|
||||||
"talkgroup_name": tgid_name,
|
"talkgroup_name": tgid_name,
|
||||||
@@ -249,6 +267,15 @@ class MQTTHandler:
|
|||||||
"ended_at": ended_at,
|
"ended_at": ended_at,
|
||||||
"status": "ended",
|
"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"):
|
if payload.get("audio_url"):
|
||||||
updates["audio_url"] = payload["audio_url"]
|
updates["audio_url"] = payload["audio_url"]
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -15,7 +15,7 @@ from app.internal.auth import (
|
|||||||
require_node_service_or_firebase_token,
|
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 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 dynsec
|
||||||
from app.internal import firestore as fstore
|
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(users.router) # auth: admin only
|
||||||
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
|
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(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
|
# 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.
|
# short-lived HMAC in the URL is the credential. Checked inline in media.py.
|
||||||
app.include_router(media.router)
|
app.include_router(media.router)
|
||||||
|
|||||||
@@ -3,6 +3,50 @@ from typing import Optional, List, Dict, Any
|
|||||||
from datetime import datetime
|
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
|
# Nodes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -10,6 +54,7 @@ from datetime import datetime
|
|||||||
class NodeRecord(BaseModel):
|
class NodeRecord(BaseModel):
|
||||||
node_id: str
|
node_id: str
|
||||||
name: str
|
name: str
|
||||||
|
org_id: Optional[str] = None # stamped at enrollment; None only on pre-tenancy docs awaiting backfill
|
||||||
lat: float = 0.0
|
lat: float = 0.0
|
||||||
lon: float = 0.0
|
lon: float = 0.0
|
||||||
status: str = "offline" # online / offline / recording / unconfigured
|
status: str = "offline" # online / offline / recording / unconfigured
|
||||||
@@ -35,6 +80,7 @@ class CommandPayload(BaseModel):
|
|||||||
|
|
||||||
class SystemRecord(BaseModel):
|
class SystemRecord(BaseModel):
|
||||||
system_id: str
|
system_id: str
|
||||||
|
org_id: Optional[str] = None
|
||||||
name: str
|
name: str
|
||||||
type: str # P25 / DMR / NBFM
|
type: str # P25 / DMR / NBFM
|
||||||
config: Dict[str, Any] = {} # OP25-compatible config blob
|
config: Dict[str, Any] = {} # OP25-compatible config blob
|
||||||
@@ -55,6 +101,7 @@ class SystemCreate(BaseModel):
|
|||||||
class CallRecord(BaseModel):
|
class CallRecord(BaseModel):
|
||||||
call_id: str
|
call_id: str
|
||||||
node_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
|
system_id: Optional[str] = None
|
||||||
talkgroup_id: Optional[int] = None
|
talkgroup_id: Optional[int] = None
|
||||||
talkgroup_name: Optional[str] = None
|
talkgroup_name: Optional[str] = None
|
||||||
@@ -78,6 +125,7 @@ class CallRecord(BaseModel):
|
|||||||
|
|
||||||
class IncidentRecord(BaseModel):
|
class IncidentRecord(BaseModel):
|
||||||
incident_id: str
|
incident_id: str
|
||||||
|
org_id: Optional[str] = None # inherited from the calls that created it — see internal/incident_correlator.py
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
type: Optional[str] = None # fire / police / ems / etc.
|
type: Optional[str] = None # fire / police / ems / etc.
|
||||||
status: str = "active" # active / resolved
|
status: str = "active" # active / resolved
|
||||||
@@ -114,6 +162,7 @@ class IncidentUpdate(BaseModel):
|
|||||||
|
|
||||||
class AlertRule(BaseModel):
|
class AlertRule(BaseModel):
|
||||||
rule_id: Optional[str] = None
|
rule_id: Optional[str] = None
|
||||||
|
org_id: Optional[str] = None
|
||||||
name: str
|
name: str
|
||||||
keywords: List[str] = []
|
keywords: List[str] = []
|
||||||
talkgroup_ids: List[int] = []
|
talkgroup_ids: List[int] = []
|
||||||
@@ -131,6 +180,7 @@ class AlertRuleUpdate(BaseModel):
|
|||||||
|
|
||||||
class AlertEvent(BaseModel):
|
class AlertEvent(BaseModel):
|
||||||
alert_id: Optional[str] = None
|
alert_id: Optional[str] = None
|
||||||
|
org_id: Optional[str] = None
|
||||||
rule_id: str
|
rule_id: str
|
||||||
rule_name: str
|
rule_name: str
|
||||||
call_id: str
|
call_id: str
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from fastapi import APIRouter, Depends, Query
|
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.feature_flags import get_flags, set_flags
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
@@ -24,8 +24,13 @@ router = APIRouter(prefix="/admin", tags=["admin"])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/features")
|
@router.get("/features")
|
||||||
async def get_feature_flags(_=Depends(require_firebase_token)):
|
async def get_feature_flags(_=Depends(require_admin_token)):
|
||||||
"""Return the current AI feature flag state. Any authenticated user can read."""
|
"""
|
||||||
|
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()
|
return await get_flags()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
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.models import AlertRule, AlertRuleUpdate
|
||||||
from app.internal import firestore as fstore
|
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"])
|
router = APIRouter(tags=["alerts"])
|
||||||
|
|
||||||
@@ -14,18 +15,31 @@ router = APIRouter(tags=["alerts"])
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/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 = {}
|
filters = {}
|
||||||
if acknowledged is not None:
|
if acknowledged is not None:
|
||||||
filters["acknowledged"] = acknowledged
|
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)
|
return await fstore.collection_list("alert_events", **filters)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/alerts/{alert_id}/acknowledge")
|
@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)
|
doc = await fstore.doc_get("alert_events", alert_id)
|
||||||
if not doc:
|
if not doc:
|
||||||
raise HTTPException(404, f"Alert '{alert_id}' not found.")
|
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})
|
await fstore.doc_update("alert_events", alert_id, {"acknowledged": True})
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -35,15 +49,23 @@ async def acknowledge_alert(alert_id: str):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/alert-rules")
|
@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")
|
return await fstore.collection_list("alert_rules")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/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())
|
rule_id = str(uuid.uuid4())
|
||||||
doc = {
|
doc = {
|
||||||
"rule_id": rule_id,
|
"rule_id": rule_id,
|
||||||
|
"org_id": org_id or FOUNDING_ORG_ID,
|
||||||
"name": body.name,
|
"name": body.name,
|
||||||
"keywords": body.keywords,
|
"keywords": body.keywords,
|
||||||
"talkgroup_ids": body.talkgroup_ids,
|
"talkgroup_ids": body.talkgroup_ids,
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.internal import firestore as fstore
|
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
|
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),
|
node_id: Optional[str] = Query(None),
|
||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
system_id: Optional[str] = Query(None),
|
system_id: Optional[str] = Query(None),
|
||||||
|
decoded: dict = Depends(require_service_or_firebase_token),
|
||||||
):
|
):
|
||||||
filters = {}
|
filters = {}
|
||||||
if node_id:
|
if node_id:
|
||||||
@@ -26,25 +32,44 @@ async def list_calls(
|
|||||||
filters["status"] = status
|
filters["status"] = status
|
||||||
if system_id:
|
if system_id:
|
||||||
filters["system_id"] = 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)
|
calls = await fstore.collection_list("calls", **filters)
|
||||||
# audio_url is not stored — it's a short-lived signed link minted per read.
|
# audio_url is not stored — it's a short-lived signed link minted per read.
|
||||||
return [with_playback_url(c) for c in calls]
|
return [with_playback_url(c) for c in calls]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{call_id}")
|
@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)
|
call = await fstore.doc_get("calls", call_id)
|
||||||
if not call:
|
if not call:
|
||||||
raise HTTPException(404, f"Call '{call_id}' not found.")
|
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)
|
return with_playback_url(call)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{call_id}/reprocess")
|
@router.post("/{call_id}/reprocess")
|
||||||
async def reprocess_call(call_id: str, background_tasks: BackgroundTasks):
|
async def reprocess_call(
|
||||||
"""Re-run the full intelligence pipeline (transcription → extraction → correlation) for a 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)
|
call = await fstore.doc_get("calls", call_id)
|
||||||
if not call:
|
if not call:
|
||||||
raise HTTPException(404, f"Call '{call_id}' not found.")
|
raise HTTPException(404, f"Call '{call_id}' not found.")
|
||||||
|
reprocess_limiter.check(call_id)
|
||||||
|
|
||||||
from app.routers.upload import _run_intelligence_pipeline
|
from app.routers.upload import _run_intelligence_pipeline
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from pydantic import BaseModel
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
|
from app.internal.tenancy import FOUNDING_ORG_ID
|
||||||
|
|
||||||
router = APIRouter(prefix="/nodes", tags=["enrollment"])
|
router = APIRouter(prefix="/nodes", tags=["enrollment"])
|
||||||
|
|
||||||
@@ -69,6 +70,31 @@ def _hash_secret(secret: str) -> str:
|
|||||||
return hashlib.sha256(secret.encode()).hexdigest()
|
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):
|
class EnrollRequest(BaseModel):
|
||||||
node_id: str
|
node_id: str
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
@@ -92,10 +118,13 @@ async def enroll_node(
|
|||||||
if not _enroll_limiter.allow(client_ip):
|
if not _enroll_limiter.allow(client_ip):
|
||||||
raise HTTPException(429, "Too many enrollment attempts. Try again later.")
|
raise HTTPException(429, "Too many enrollment attempts. Try again later.")
|
||||||
|
|
||||||
if not settings.enrollment_token:
|
if not x_enrollment_token:
|
||||||
raise HTTPException(503, "Enrollment is not configured on this server.")
|
logger.warning(f"Enroll 401: missing X-Enrollment-Token from {client_ip} for node_id={body.node_id!r}")
|
||||||
if not x_enrollment_token or not secrets.compare_digest(x_enrollment_token, settings.enrollment_token):
|
raise HTTPException(401, "Invalid or missing X-Enrollment-Token")
|
||||||
logger.warning(f"Enroll 401: bad/missing enrollment token from {client_ip} for node_id={body.node_id!r}")
|
|
||||||
|
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")
|
raise HTTPException(401, "Invalid or missing X-Enrollment-Token")
|
||||||
|
|
||||||
node_id = body.node_id.strip()
|
node_id = body.node_id.strip()
|
||||||
@@ -136,6 +165,11 @@ async def enroll_node(
|
|||||||
pickup_secret = secrets.token_hex(24)
|
pickup_secret = secrets.token_hex(24)
|
||||||
doc = {
|
doc = {
|
||||||
"node_id": node_id,
|
"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,
|
"name": body.name or (existing or {}).get("name") or node_id,
|
||||||
"lat": body.lat or (existing or {}).get("lat", 0.0),
|
"lat": body.lat or (existing or {}).get("lat", 0.0),
|
||||||
"lon": body.lon or (existing or {}).get("lon", 0.0),
|
"lon": body.lon or (existing or {}).get("lon", 0.0),
|
||||||
|
|||||||
@@ -4,18 +4,30 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
|
||||||
from app.models import IncidentCreate, IncidentUpdate
|
from app.models import IncidentCreate, IncidentUpdate
|
||||||
from app.internal import firestore as fstore
|
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 = APIRouter(prefix="/incidents", tags=["incidents"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@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 = {}
|
filters = {}
|
||||||
if status:
|
if status:
|
||||||
filters["status"] = status
|
filters["status"] = status
|
||||||
if type:
|
if type:
|
||||||
filters["type"] = 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)
|
return await fstore.collection_list("incidents", **filters)
|
||||||
|
|
||||||
|
|
||||||
@@ -31,10 +43,13 @@ async def summarize_all_stale(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{incident_id}")
|
@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)
|
doc = await fstore.doc_get("incidents", incident_id)
|
||||||
if not doc:
|
if not doc:
|
||||||
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
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
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import asyncio
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||||
|
from firebase_admin import auth as firebase_auth
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from app.internal import firestore as fstore
|
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
|
from app.internal.logger import logger
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
@@ -129,6 +131,91 @@ async def unlink(decoded: dict = Depends(require_firebase_token)):
|
|||||||
return {"ok": True}
|
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
|
# Session recording — called by the frontend on each successful sign-in
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -7,22 +7,33 @@ from app.internal import firestore as fstore
|
|||||||
from app.internal.mqtt_handler import mqtt_handler
|
from app.internal.mqtt_handler import mqtt_handler
|
||||||
from app.internal import dynsec
|
from app.internal import dynsec
|
||||||
from app.internal.logger import logger
|
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
|
from app.routers.tokens import assign_token, release_token
|
||||||
|
|
||||||
router = APIRouter(prefix="/nodes", tags=["nodes"])
|
router = APIRouter(prefix="/nodes", tags=["nodes"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def 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")
|
||||||
|
return await fstore.collection_list("nodes", org_id=org_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{node_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)
|
node = await fstore.doc_get("nodes", node_id)
|
||||||
if not node:
|
if not node:
|
||||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
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
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
from app.models import SystemCreate, SystemRecord
|
from app.models import SystemCreate, SystemRecord
|
||||||
from app.internal import firestore as fstore
|
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"])
|
router = APIRouter(prefix="/systems", tags=["systems"])
|
||||||
|
|
||||||
@@ -23,22 +29,32 @@ class AiFlagsBody(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def 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")
|
||||||
|
return await fstore.collection_list("systems", org_id=org_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{system_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)
|
system = await fstore.doc_get("systems", system_id)
|
||||||
if not system:
|
if not system:
|
||||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
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
|
return system
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@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())
|
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)
|
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
|
||||||
return doc
|
return doc
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ from app.internal.auth import (
|
|||||||
require_service_or_firebase_token,
|
require_service_or_firebase_token,
|
||||||
require_service_key,
|
require_service_key,
|
||||||
require_service_key_or_admin,
|
require_service_key_or_admin,
|
||||||
|
get_role,
|
||||||
trip_chat_limiter,
|
trip_chat_limiter,
|
||||||
)
|
)
|
||||||
|
from app.internal.tenancy import FOUNDING_ORG_ID
|
||||||
|
|
||||||
router = APIRouter(prefix="/trips", tags=["trips"])
|
router = APIRouter(prefix="/trips", tags=["trips"])
|
||||||
|
|
||||||
@@ -23,6 +25,22 @@ router = APIRouter(prefix="/trips", tags=["trips"])
|
|||||||
# Access control helpers
|
# 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]:
|
async def _discord_id_for_firebase(firebase_uid: str) -> Optional[str]:
|
||||||
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
||||||
return (link or {}).get("discord_user_id")
|
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("")
|
@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:
|
if body.end_date < body.start_date:
|
||||||
raise HTTPException(400, "end_date must be on or after start_date.")
|
raise HTTPException(400, "end_date must be on or after start_date.")
|
||||||
trip_id = str(uuid.uuid4())
|
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")
|
@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."""
|
"""Replace the trip's available tag list and overlap-allowed tag list."""
|
||||||
|
_require_founding_org(decoded)
|
||||||
trip = await fstore.doc_get("trips", trip_id)
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
if not trip:
|
if not trip:
|
||||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||||
@@ -363,7 +383,8 @@ async def leave_trip(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{trip_id}/events")
|
@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)
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
if not trip:
|
if not trip:
|
||||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
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}")
|
@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)
|
event = await fstore.doc_get("trip_events", event_id)
|
||||||
if not event or event.get("trip_id") != trip_id:
|
if not event or event.get("trip_id") != trip_id:
|
||||||
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
||||||
|
|||||||
@@ -54,7 +54,17 @@ async def upload_call_audio(
|
|||||||
try:
|
try:
|
||||||
# Canonical object location only. The playback link is minted per
|
# Canonical object location only. The playback link is minted per
|
||||||
# read in storage.playback_url() — nothing durable is stored here.
|
# 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:
|
except Exception as e:
|
||||||
logger.warning(f"Could not update call {call_id} with audio_gcs_uri: {e}")
|
logger.warning(f"Could not update call {call_id} with audio_gcs_uri: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Backfill org_id onto every pre-tenancy document and create the founding org.
|
||||||
|
|
||||||
|
*** WRITE-ONLY REFERENCE — NOT RUN AS PART OF THIS CHANGE. ***
|
||||||
|
SAAS_PLAN.md B2 explicitly says "write it; do not run it" — this script
|
||||||
|
touches production Firestore (organizations, org_members, nodes, systems,
|
||||||
|
calls, incidents, alert_rules, alert_events) and Firebase Auth custom
|
||||||
|
claims. Read this whole docstring before ever running it anywhere.
|
||||||
|
|
||||||
|
WHY IT'S NEEDED: as of this pass, every doc in the six tenant collections
|
||||||
|
below predates the org_id field entirely (org_id is Optional[...] = None on
|
||||||
|
every model in app/models.py specifically to allow this). c2-core's read
|
||||||
|
routes and infra/firestore/firestore.rules now filter/require org_id, so
|
||||||
|
until this runs, pre-existing docs are invisible through the org-scoped
|
||||||
|
paths — they still exist, they're just unreachable by a caller whose token
|
||||||
|
carries an org_id claim. New docs created going forward (enrollment.py,
|
||||||
|
mqtt_handler.py, upload.py, incident_correlator.py) already stamp org_id
|
||||||
|
themselves; this script only needs to run ONCE, retroactively, and is safe
|
||||||
|
to re-run after that (idempotent — see below).
|
||||||
|
|
||||||
|
WHAT IT DOES:
|
||||||
|
1. Creates organizations/{FOUNDING_ORG_ID} if it doesn't already exist.
|
||||||
|
FOUNDING_ORG_ID ("founding") is the same id app/internal/tenancy.py
|
||||||
|
defines and the same id enrollment.py's legacy fleet-wide
|
||||||
|
ENROLLMENT_TOKEN fallback and mqtt_handler.py's legacy MQTT-checkin
|
||||||
|
path both already resolve brand-new nodes into — so a node that
|
||||||
|
enrolled the old way and a doc backfilled by this script end up in the
|
||||||
|
same org.
|
||||||
|
2. Sets --owner-email's org_id/org_role Firebase custom claims and writes
|
||||||
|
their org_members doc — the same shape POST /auth/signup writes for a
|
||||||
|
self-serve org, so this person becomes the founding org's owner in the
|
||||||
|
UI exactly as if they'd signed up normally. Their platform `role`
|
||||||
|
claim is left alone if already set, else defaults to "admin" (the
|
||||||
|
backfill owner is presumed to be today's single-tenant deployment's
|
||||||
|
admin).
|
||||||
|
3. Walks nodes / systems / calls / incidents / alert_rules / alert_events
|
||||||
|
and stamps org_id = FOUNDING_ORG_ID onto every document that doesn't
|
||||||
|
already have one. A document that already has org_id (from the
|
||||||
|
post-tenancy code paths that shipped alongside this script) is left
|
||||||
|
untouched — this is what makes a second run a no-op rather than a
|
||||||
|
re-stamp, so running it twice by accident is harmless.
|
||||||
|
|
||||||
|
USAGE (run from the drb-c2-core directory, with GCP_CREDENTIALS_PATH set or
|
||||||
|
Application Default Credentials available — same auth as set_admin.py):
|
||||||
|
|
||||||
|
python scripts/backfill_org_id.py --owner-email you@example.com --dry-run
|
||||||
|
python scripts/backfill_org_id.py --owner-email you@example.com
|
||||||
|
|
||||||
|
ALWAYS run with --dry-run first and read every line of its output — it
|
||||||
|
prints exactly what would be created/changed, with no writes, before you
|
||||||
|
run it for real. --dry-run performs full collection scans (read-only) to
|
||||||
|
produce accurate counts; on a large calls/incidents collection this is not
|
||||||
|
free, but it is the only way to know the real backfill count in advance.
|
||||||
|
|
||||||
|
NOT HANDLED: org_api_keys (collection doesn't exist server-side yet — see
|
||||||
|
DEFERRED.md) and node_keys (deliberately never gets an org_id column; it's
|
||||||
|
looked up by node_id / api_key value, not read as an org-scoped list).
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import firebase_admin
|
||||||
|
from firebase_admin import auth, credentials, firestore
|
||||||
|
|
||||||
|
# Mirrors app/internal/tenancy.py — duplicated rather than imported so this
|
||||||
|
# script has no dependency on the app package (or its settings/env) being
|
||||||
|
# importable from wherever it's actually run.
|
||||||
|
FOUNDING_ORG_ID = "founding"
|
||||||
|
|
||||||
|
TENANT_COLLECTIONS = ["nodes", "systems", "calls", "incidents", "alert_rules", "alert_events"]
|
||||||
|
|
||||||
|
# Firestore batched writes cap at 500 operations; stay comfortably under it.
|
||||||
|
_BATCH_SIZE = 400
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument("--owner-email", required=True, help="Firebase user who becomes the founding org's owner")
|
||||||
|
parser.add_argument("--org-name", default="Founding Org", help="Display name for the founding org")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Print what would change; write nothing")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
creds_path = os.getenv("GCP_CREDENTIALS_PATH", "gcp-key.json")
|
||||||
|
cred = credentials.Certificate(creds_path)
|
||||||
|
firebase_admin.initialize_app(cred)
|
||||||
|
db = firestore.client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
owner = auth.get_user_by_email(args.owner_email)
|
||||||
|
except auth.UserNotFoundError:
|
||||||
|
print(f"No Firebase user found for {args.owner_email!r}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
existing_claims = owner.custom_claims or {}
|
||||||
|
|
||||||
|
org_ref = db.collection("organizations").document(FOUNDING_ORG_ID)
|
||||||
|
org_exists = org_ref.get().exists
|
||||||
|
print(f"organizations/{FOUNDING_ORG_ID}: {'exists — left alone' if org_exists else 'WILL CREATE'}")
|
||||||
|
print(f"org_members/{owner.uid}: WILL SET org_role='owner' (email={args.owner_email})")
|
||||||
|
print(
|
||||||
|
f"Firebase custom claims for {args.owner_email}: WILL SET org_id={FOUNDING_ORG_ID!r} org_role='owner', "
|
||||||
|
f"role={existing_claims.get('role', 'admin (default)')!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
counts: dict[str, tuple[int, int]] = {}
|
||||||
|
total_missing = 0
|
||||||
|
for collection in TENANT_COLLECTIONS:
|
||||||
|
docs = list(db.collection(collection).stream())
|
||||||
|
missing = [d for d in docs if not (d.to_dict() or {}).get("org_id")]
|
||||||
|
counts[collection] = (len(docs), len(missing))
|
||||||
|
total_missing += len(missing)
|
||||||
|
print(f"{collection}: {len(docs)} docs total, {len(missing)} missing org_id")
|
||||||
|
|
||||||
|
print(f"\nTotal documents to backfill: {total_missing}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("\n--dry-run: no writes performed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not org_exists:
|
||||||
|
org_ref.set({
|
||||||
|
"org_id": FOUNDING_ORG_ID,
|
||||||
|
"name": args.org_name,
|
||||||
|
"created_at": now,
|
||||||
|
"created_by_uid": owner.uid,
|
||||||
|
# Inert placeholders — no billing model exists yet, see
|
||||||
|
# app/internal/tenancy.py and models.py's OrganizationRecord.
|
||||||
|
"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,
|
||||||
|
})
|
||||||
|
print(f"Created organizations/{FOUNDING_ORG_ID}.")
|
||||||
|
|
||||||
|
db.collection("org_members").document(owner.uid).set({
|
||||||
|
"uid": owner.uid,
|
||||||
|
"org_id": FOUNDING_ORG_ID,
|
||||||
|
"org_role": "owner",
|
||||||
|
"email": args.owner_email,
|
||||||
|
"added_at": now,
|
||||||
|
}, merge=True)
|
||||||
|
print(f"Set org_members/{owner.uid}.")
|
||||||
|
|
||||||
|
new_claims = {**existing_claims, "org_id": FOUNDING_ORG_ID, "org_role": "owner"}
|
||||||
|
new_claims.setdefault("role", "admin")
|
||||||
|
auth.set_custom_user_claims(owner.uid, new_claims)
|
||||||
|
print(f"Set custom claims for {args.owner_email}.")
|
||||||
|
|
||||||
|
for collection in TENANT_COLLECTIONS:
|
||||||
|
_, missing_count = counts[collection]
|
||||||
|
if not missing_count:
|
||||||
|
print(f"{collection}: nothing to backfill.")
|
||||||
|
continue
|
||||||
|
batch = db.batch()
|
||||||
|
batch_count = 0
|
||||||
|
written = 0
|
||||||
|
for doc in db.collection(collection).stream():
|
||||||
|
if (doc.to_dict() or {}).get("org_id"):
|
||||||
|
continue
|
||||||
|
batch.update(doc.reference, {"org_id": FOUNDING_ORG_ID})
|
||||||
|
batch_count += 1
|
||||||
|
written += 1
|
||||||
|
if batch_count >= _BATCH_SIZE:
|
||||||
|
batch.commit()
|
||||||
|
batch = db.batch()
|
||||||
|
batch_count = 0
|
||||||
|
if batch_count:
|
||||||
|
batch.commit()
|
||||||
|
print(f"{collection}: backfilled {written} document(s).")
|
||||||
|
|
||||||
|
print("\nDone. The owner must sign out and back in (or wait up to 1 hour) for the new claims to take effect.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user