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,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
|
||||
@@ -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}'.")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user