Files
server-26/drb-c2-core/app/models.py
T
Logan CusanoandClaude Opus 5 a3681ea698
Build & Deploy / Build & push images (push) Successful in 4m5s
Build & Deploy / Deploy to VM (push) Successful in 1m59s
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>
2026-08-18 20:28:37 -04:00

239 lines
8.1 KiB
Python

from pydantic import BaseModel, Field
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
# ---------------------------------------------------------------------------
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
configured: bool = False
last_seen: Optional[datetime] = None
assigned_system_id: Optional[str] = None
node_type: str = "fixed" # fixed or portable
enforce_override_timeout: bool = True
is_overridden: bool = False
override_system_id: Optional[str] = None
override_timeout_at: Optional[datetime] = None
class CommandPayload(BaseModel):
action: str # discord_join / discord_leave / op25_restart
guild_id: Optional[str] = None
channel_id: Optional[str] = None
# ---------------------------------------------------------------------------
# Systems
# ---------------------------------------------------------------------------
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
ten_codes: Dict[str, str] = {} # {"10-10": "Commercial Alarm", ...}
class SystemCreate(BaseModel):
name: str
type: str
config: Dict[str, Any] = {}
ten_codes: Dict[str, str] = {}
# ---------------------------------------------------------------------------
# Calls
# ---------------------------------------------------------------------------
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
freq: Optional[float] = None
srcaddr: Optional[str] = None
started_at: datetime
ended_at: Optional[datetime] = None
audio_gcs_uri: Optional[str] = None # canonical gs:// object location
audio_url: Optional[str] = None # NOT stored — minted per read, see internal/storage.py
duplicate_of: Optional[str] = None # another node recorded this same transmission first
transcript: Optional[str] = None # populated later by STT
incident_ids: List[str] = [] # one per scene detected in the recording
location: Optional[Dict[str, float]] = None # {lat, lng}
tags: List[str] = []
status: str = "active" # active / ended
# ---------------------------------------------------------------------------
# Incidents
# ---------------------------------------------------------------------------
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
location: Optional[Dict[str, float]] = None
call_ids: List[str] = []
started_at: datetime
updated_at: datetime
summary: Optional[str] = None
tags: List[str] = []
class IncidentCreate(BaseModel):
title: str
type: str = "other"
status: str = "active"
location: Optional[Dict[str, float]] = None
call_ids: List[str] = []
summary: Optional[str] = None
tags: List[str] = []
class IncidentUpdate(BaseModel):
title: Optional[str] = None
type: Optional[str] = None
status: Optional[str] = None
location: Optional[Dict[str, float]] = None
summary: Optional[str] = None
tags: Optional[List[str]] = None
# ---------------------------------------------------------------------------
# Alerts
# ---------------------------------------------------------------------------
class AlertRule(BaseModel):
rule_id: Optional[str] = None
org_id: Optional[str] = None
name: str
keywords: List[str] = []
talkgroup_ids: List[int] = []
enabled: bool = True
discord_webhook: Optional[str] = None # POST here when rule fires
class AlertRuleUpdate(BaseModel):
name: Optional[str] = None
keywords: Optional[List[str]] = None
talkgroup_ids: Optional[List[int]] = None
enabled: Optional[bool] = None
discord_webhook: Optional[str] = None
class AlertEvent(BaseModel):
alert_id: Optional[str] = None
org_id: Optional[str] = None
rule_id: str
rule_name: str
call_id: str
node_id: str
talkgroup_id: Optional[int] = None
talkgroup_name: Optional[str] = None
matched_keywords: List[str] = []
transcript_snippet: Optional[str] = None
triggered_at: Optional[datetime] = None
acknowledged: bool = False
# ---------------------------------------------------------------------------
# Trips
# ---------------------------------------------------------------------------
class TripCreate(BaseModel):
name: str
location: str
maps_link: Optional[str] = None
start_date: str # YYYY-MM-DD
end_date: str # YYYY-MM-DD
available_tags: List[str] = [] # tag labels configured for this trip
overlap_tags: List[str] = [] # subset of available_tags that allow time overlap
visibility: str = "public" # "public" | "private"
invited_discord_ids: List[str] = [] # discord user IDs allowed on private trips
class TripEventCreate(BaseModel):
title: str
date: str # YYYY-MM-DD, must fall within parent trip range
start_time: Optional[str] = None # HH:MM (24h)
end_time: Optional[str] = None # HH:MM (24h)
location: Optional[str] = None # inherits trip location if None
maps_link: Optional[str] = None
place_id: Optional[str] = None # Google Place ID
notes: Optional[str] = None
tags: List[str] = [] # tag labels applied to this event
class TripEventUpdate(BaseModel):
title: Optional[str] = None
date: Optional[str] = None
start_time: Optional[str] = None
end_time: Optional[str] = None
location: Optional[str] = None
maps_link: Optional[str] = None
place_id: Optional[str] = None
notes: Optional[str] = None
tags: Optional[List[str]] = None
class AttendeeAction(BaseModel):
discord_user_id: str
discord_username: Optional[str] = None