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>
240 lines
9.5 KiB
Python
240 lines
9.5 KiB
Python
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, get_role
|
|
from app.internal.logger import logger
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
_CODE_TTL_MINUTES = 15
|
|
|
|
|
|
def _gen_code() -> str:
|
|
return "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Web: generate a short-lived linking code
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.post("/link/generate")
|
|
async def generate_link_code(decoded: dict = Depends(require_firebase_token)):
|
|
"""Authenticated Firebase user generates a code to paste into Discord /link."""
|
|
firebase_uid = decoded["uid"]
|
|
|
|
# Check if already linked
|
|
existing = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
|
if existing and existing.get("discord_user_id"):
|
|
return {
|
|
"already_linked": True,
|
|
"discord_user_id": existing["discord_user_id"],
|
|
}
|
|
|
|
code = _gen_code()
|
|
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=_CODE_TTL_MINUTES)).isoformat()
|
|
await fstore.doc_set("link_codes", code, {
|
|
"firebase_uid": firebase_uid,
|
|
"expires_at": expires_at,
|
|
}, merge=False)
|
|
|
|
return {"code": code, "expires_minutes": _CODE_TTL_MINUTES}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Discord bot: resolve a code and store the link
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class LinkResolveBody(BaseModel):
|
|
code: str
|
|
discord_user_id: str
|
|
discord_username: str = ""
|
|
|
|
|
|
@router.post("/link")
|
|
async def resolve_link_code(body: LinkResolveBody, _: dict = Depends(require_service_key)):
|
|
"""Discord bot resolves a linking code and permanently links the accounts."""
|
|
doc = await fstore.doc_get("link_codes", body.code.upper().strip())
|
|
if not doc:
|
|
raise HTTPException(404, "Invalid or expired code.")
|
|
|
|
expires_at = datetime.fromisoformat(doc["expires_at"])
|
|
if datetime.now(timezone.utc) > expires_at:
|
|
await fstore.doc_delete("link_codes", body.code)
|
|
raise HTTPException(410, "Code has expired. Generate a new one from the web app.")
|
|
|
|
firebase_uid = doc["firebase_uid"]
|
|
|
|
# Check if this Discord account is already linked to a different Firebase UID
|
|
existing = await fstore.doc_get("discord_links", body.discord_user_id)
|
|
if existing and existing.get("firebase_uid") and existing["firebase_uid"] != firebase_uid:
|
|
raise HTTPException(409, "This Discord account is already linked to a different account.")
|
|
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
|
|
# Store both directions
|
|
await fstore.doc_set("discord_links", body.discord_user_id, {
|
|
"firebase_uid": firebase_uid,
|
|
"discord_username": body.discord_username,
|
|
"linked_at": now,
|
|
}, merge=False)
|
|
|
|
await fstore.doc_set("firebase_discord_links", firebase_uid, {
|
|
"discord_user_id": body.discord_user_id,
|
|
"discord_username": body.discord_username,
|
|
"linked_at": now,
|
|
}, merge=False)
|
|
|
|
# Clean up the code
|
|
await fstore.doc_delete("link_codes", body.code)
|
|
|
|
logger.info(f"Linked firebase_uid={firebase_uid} <-> discord_user_id={body.discord_user_id}")
|
|
return {"ok": True, "firebase_uid": firebase_uid}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Web: check current link status
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/link/status")
|
|
async def link_status(decoded: dict = Depends(require_firebase_token)):
|
|
firebase_uid = decoded["uid"]
|
|
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
|
if link and link.get("discord_user_id"):
|
|
return {
|
|
"linked": True,
|
|
"discord_user_id": link["discord_user_id"],
|
|
"discord_username": link.get("discord_username", ""),
|
|
"linked_at": link.get("linked_at"),
|
|
}
|
|
return {"linked": False}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Web: unlink
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.delete("/link")
|
|
async def unlink(decoded: dict = Depends(require_firebase_token)):
|
|
firebase_uid = decoded["uid"]
|
|
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
|
if not link or not link.get("discord_user_id"):
|
|
raise HTTPException(404, "No linked Discord account.")
|
|
discord_user_id = link["discord_user_id"]
|
|
await fstore.doc_delete("discord_links", discord_user_id)
|
|
await fstore.doc_delete("firebase_discord_links", firebase_uid)
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.post("/session")
|
|
async def record_session(request: Request, decoded: dict = Depends(require_firebase_token)):
|
|
"""Record a sign-in event for the authenticated user."""
|
|
session_id = str(uuid4())
|
|
ip = request.client.host if request.client else None
|
|
user_agent = request.headers.get("user-agent", "")
|
|
|
|
await fstore.doc_set("user_sessions", session_id, {
|
|
"session_id": session_id,
|
|
"uid": decoded["uid"],
|
|
"email": decoded.get("email", ""),
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"ip": ip,
|
|
"user_agent": user_agent,
|
|
}, merge=False)
|
|
|
|
return {"ok": True}
|