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
@@ -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}
|
||||
Reference in New Issue
Block a user