Files
server-26/drb-c2-core/scripts/backfill_org_id.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

187 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""
Backfill org_id onto every pre-tenancy document and create the founding org.
*** WRITE-ONLY REFERENCE — NOT RUN AS PART OF THIS CHANGE. ***
SAAS_PLAN.md B2 explicitly says "write it; do not run it" — this script
touches production Firestore (organizations, org_members, nodes, systems,
calls, incidents, alert_rules, alert_events) and Firebase Auth custom
claims. Read this whole docstring before ever running it anywhere.
WHY IT'S NEEDED: as of this pass, every doc in the six tenant collections
below predates the org_id field entirely (org_id is Optional[...] = None on
every model in app/models.py specifically to allow this). c2-core's read
routes and infra/firestore/firestore.rules now filter/require org_id, so
until this runs, pre-existing docs are invisible through the org-scoped
paths — they still exist, they're just unreachable by a caller whose token
carries an org_id claim. New docs created going forward (enrollment.py,
mqtt_handler.py, upload.py, incident_correlator.py) already stamp org_id
themselves; this script only needs to run ONCE, retroactively, and is safe
to re-run after that (idempotent — see below).
WHAT IT DOES:
1. Creates organizations/{FOUNDING_ORG_ID} if it doesn't already exist.
FOUNDING_ORG_ID ("founding") is the same id app/internal/tenancy.py
defines and the same id enrollment.py's legacy fleet-wide
ENROLLMENT_TOKEN fallback and mqtt_handler.py's legacy MQTT-checkin
path both already resolve brand-new nodes into — so a node that
enrolled the old way and a doc backfilled by this script end up in the
same org.
2. Sets --owner-email's org_id/org_role Firebase custom claims and writes
their org_members doc — the same shape POST /auth/signup writes for a
self-serve org, so this person becomes the founding org's owner in the
UI exactly as if they'd signed up normally. Their platform `role`
claim is left alone if already set, else defaults to "admin" (the
backfill owner is presumed to be today's single-tenant deployment's
admin).
3. Walks nodes / systems / calls / incidents / alert_rules / alert_events
and stamps org_id = FOUNDING_ORG_ID onto every document that doesn't
already have one. A document that already has org_id (from the
post-tenancy code paths that shipped alongside this script) is left
untouched — this is what makes a second run a no-op rather than a
re-stamp, so running it twice by accident is harmless.
USAGE (run from the drb-c2-core directory, with GCP_CREDENTIALS_PATH set or
Application Default Credentials available — same auth as set_admin.py):
python scripts/backfill_org_id.py --owner-email you@example.com --dry-run
python scripts/backfill_org_id.py --owner-email you@example.com
ALWAYS run with --dry-run first and read every line of its output — it
prints exactly what would be created/changed, with no writes, before you
run it for real. --dry-run performs full collection scans (read-only) to
produce accurate counts; on a large calls/incidents collection this is not
free, but it is the only way to know the real backfill count in advance.
NOT HANDLED: org_api_keys (collection doesn't exist server-side yet — see
DEFERRED.md) and node_keys (deliberately never gets an org_id column; it's
looked up by node_id / api_key value, not read as an org-scoped list).
"""
import argparse
import os
import sys
from datetime import datetime, timezone
import firebase_admin
from firebase_admin import auth, credentials, firestore
# Mirrors app/internal/tenancy.py — duplicated rather than imported so this
# script has no dependency on the app package (or its settings/env) being
# importable from wherever it's actually run.
FOUNDING_ORG_ID = "founding"
TENANT_COLLECTIONS = ["nodes", "systems", "calls", "incidents", "alert_rules", "alert_events"]
# Firestore batched writes cap at 500 operations; stay comfortably under it.
_BATCH_SIZE = 400
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--owner-email", required=True, help="Firebase user who becomes the founding org's owner")
parser.add_argument("--org-name", default="Founding Org", help="Display name for the founding org")
parser.add_argument("--dry-run", action="store_true", help="Print what would change; write nothing")
args = parser.parse_args()
creds_path = os.getenv("GCP_CREDENTIALS_PATH", "gcp-key.json")
cred = credentials.Certificate(creds_path)
firebase_admin.initialize_app(cred)
db = firestore.client()
try:
owner = auth.get_user_by_email(args.owner_email)
except auth.UserNotFoundError:
print(f"No Firebase user found for {args.owner_email!r}")
sys.exit(1)
now = datetime.now(timezone.utc).isoformat()
existing_claims = owner.custom_claims or {}
org_ref = db.collection("organizations").document(FOUNDING_ORG_ID)
org_exists = org_ref.get().exists
print(f"organizations/{FOUNDING_ORG_ID}: {'exists — left alone' if org_exists else 'WILL CREATE'}")
print(f"org_members/{owner.uid}: WILL SET org_role='owner' (email={args.owner_email})")
print(
f"Firebase custom claims for {args.owner_email}: WILL SET org_id={FOUNDING_ORG_ID!r} org_role='owner', "
f"role={existing_claims.get('role', 'admin (default)')!r}"
)
counts: dict[str, tuple[int, int]] = {}
total_missing = 0
for collection in TENANT_COLLECTIONS:
docs = list(db.collection(collection).stream())
missing = [d for d in docs if not (d.to_dict() or {}).get("org_id")]
counts[collection] = (len(docs), len(missing))
total_missing += len(missing)
print(f"{collection}: {len(docs)} docs total, {len(missing)} missing org_id")
print(f"\nTotal documents to backfill: {total_missing}")
if args.dry_run:
print("\n--dry-run: no writes performed.")
return
if not org_exists:
org_ref.set({
"org_id": FOUNDING_ORG_ID,
"name": args.org_name,
"created_at": now,
"created_by_uid": owner.uid,
# Inert placeholders — no billing model exists yet, see
# app/internal/tenancy.py and models.py's OrganizationRecord.
"plan_id": None,
"subscription_status": None,
"stripe_customer_id": None,
"stripe_subscription_id": None,
"current_period_end": None,
"seat_limit": None,
"node_limit": None,
"retention_days": None,
})
print(f"Created organizations/{FOUNDING_ORG_ID}.")
db.collection("org_members").document(owner.uid).set({
"uid": owner.uid,
"org_id": FOUNDING_ORG_ID,
"org_role": "owner",
"email": args.owner_email,
"added_at": now,
}, merge=True)
print(f"Set org_members/{owner.uid}.")
new_claims = {**existing_claims, "org_id": FOUNDING_ORG_ID, "org_role": "owner"}
new_claims.setdefault("role", "admin")
auth.set_custom_user_claims(owner.uid, new_claims)
print(f"Set custom claims for {args.owner_email}.")
for collection in TENANT_COLLECTIONS:
_, missing_count = counts[collection]
if not missing_count:
print(f"{collection}: nothing to backfill.")
continue
batch = db.batch()
batch_count = 0
written = 0
for doc in db.collection(collection).stream():
if (doc.to_dict() or {}).get("org_id"):
continue
batch.update(doc.reference, {"org_id": FOUNDING_ORG_ID})
batch_count += 1
written += 1
if batch_count >= _BATCH_SIZE:
batch.commit()
batch = db.batch()
batch_count = 0
if batch_count:
batch.commit()
print(f"{collection}: backfilled {written} document(s).")
print("\nDone. The owner must sign out and back in (or wait up to 1 hour) for the new claims to take effect.")
if __name__ == "__main__":
main()