Files
server-26/drb-c2-core/app/routers/enrollment.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

219 lines
9.6 KiB
Python

"""
Node self-enrollment — the public replacement for the old shared-MQTT-
password flow (see MQTT-PUBLIC-AUTH-PLAN.md "Enrollment flow").
1. POST /nodes/enroll (X-Enrollment-Token: <fleet-wide token>)
First-boot node upserts itself as `approval_status: pending` and gets
back a one-time pickup_secret. Only its hash is persisted.
2. GET /nodes/{id}/credentials (X-Pickup-Secret: <secret from step 1>)
Node polls this with backoff until an admin approves it in the
frontend (existing nodes.py approve_node() flow — unchanged, still
writes node_keys/{id}.api_key) and then reads its api_key back.
These two endpoints are meant to be public (unlike the dynsec control-plane
traffic in app/internal/dynsec.py, which never leaves the docker-internal
MQTT bridge) — that's the whole point of moving off WireGuard-per-node.
Auth is the token headers checked inline below, not the app-wide
Firebase/service-key dependency the rest of routers/nodes.py uses.
"""
import hashlib
import secrets
import time
from typing import Optional
from fastapi import APIRouter, HTTPException, Header, Request
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"])
# ---------------------------------------------------------------------------
# Per-source-IP token bucket for /nodes/enroll.
#
# c2-core has no rate-limiting dependency anywhere today (see
# MQTT-PUBLIC-AUTH-PLAN.md); this is a deliberately small (~30 line)
# in-memory limiter rather than a new library. Known limitations:
# - per-process: with more than one c2-core instance, each has its own
# bucket, so real throughput is (limit x instance count). Fine today —
# there is exactly one instance.
# - resets on every restart/redeploy — not persisted anywhere.
# Good enough to blunt casual guessing of node_ids against the fleet token;
# not a substitute for a real edge/WAF rate limiter if this endpoint is
# ever seriously targeted.
# ---------------------------------------------------------------------------
class _TokenBucket:
def __init__(self, capacity: int, refill_per_sec: float):
self.capacity = capacity
self.refill_per_sec = refill_per_sec
self._buckets: dict[str, tuple[float, float]] = {} # key -> (tokens, last_refill_ts)
def allow(self, key: str) -> bool:
now = time.monotonic()
tokens, last_ts = self._buckets.get(key, (float(self.capacity), now))
tokens = min(self.capacity, tokens + (now - last_ts) * self.refill_per_sec)
if tokens < 1:
self._buckets[key] = (tokens, now)
return False
self._buckets[key] = (tokens - 1, now)
return True
# Burst of 5, refilling 1/minute — enrollment is a first-boot, once-per-node
# event, so a legitimate node never needs more than a handful of attempts.
_enroll_limiter = _TokenBucket(capacity=5, refill_per_sec=1 / 60)
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
lat: float = 0.0
lon: float = 0.0
class EnrollResponse(BaseModel):
node_id: str
pickup_secret: str
approval_status: str
@router.post("/enroll", response_model=EnrollResponse)
async def enroll_node(
body: EnrollRequest,
request: Request,
x_enrollment_token: Optional[str] = Header(None),
):
client_ip = request.client.host if request.client else "unknown"
if not _enroll_limiter.allow(client_ip):
raise HTTPException(429, "Too many enrollment attempts. Try again later.")
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()
if not node_id:
raise HTTPException(400, "node_id is required")
existing = await fstore.doc_get("nodes", node_id)
# -------------------------------------------------------------------
# CRITICAL GUARD — do not remove or weaken this check.
#
# An already-approved node_id must NEVER get a fresh pickup_secret off
# the fleet-wide enrollment token alone. The fleet token is shared by
# every node (it ships in every node's .env / setup.sh prompt), so it
# is the credential most likely to leak. Without this guard, a leaked
# fleet token plus a guessable node_id (node-001, node-002, ...) would
# let an attacker "re-enroll" a live, already-approved node and race
# the real node to GET /nodes/{id}/credentials — stealing its actual
# api_key before the legitimate device ever asks.
#
# Recovery for an approved node goes through the existing admin-only
# POST /nodes/{id}/reissue-key instead (routers/nodes.py), which
# requires a Firebase admin token, not the fleet token.
# -------------------------------------------------------------------
if existing and existing.get("approval_status") == "approved":
logger.warning(
f"Enroll refused: node_id={node_id!r} is already approved — "
f"refusing to issue a new pickup_secret from the fleet token alone "
f"(source_ip={client_ip})"
)
raise HTTPException(
403,
"Node is already approved. This endpoint cannot re-issue credentials "
"for an approved node from the enrollment token alone — use admin "
"key reissue.",
)
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),
"approval_status": (existing or {}).get("approval_status", "pending"),
"pickup_secret_hash": _hash_secret(pickup_secret),
}
# approval_status stays "pending" for a brand-new node; if it's an
# existing "pending" or "rejected" node re-enrolling (e.g. lost its
# pickup_secret before an admin ever approved it), leave whatever
# status it already has rather than silently flipping "rejected" back
# to "pending" — that decision belongs to an admin, not this endpoint.
await fstore.doc_set("nodes", node_id, doc, merge=True)
logger.info(f"Node enrolled: {node_id} (status={doc['approval_status']}, source_ip={client_ip})")
return EnrollResponse(node_id=node_id, pickup_secret=pickup_secret, approval_status=doc["approval_status"])
class CredentialsResponse(BaseModel):
approval_status: str
api_key: Optional[str] = None
@router.get("/{node_id}/credentials", response_model=CredentialsResponse)
async def get_node_credentials(node_id: str, x_pickup_secret: Optional[str] = Header(None)):
if not x_pickup_secret:
raise HTTPException(401, "Missing X-Pickup-Secret header")
node = await fstore.doc_get("nodes", node_id)
if not node or not node.get("pickup_secret_hash"):
raise HTTPException(404, "Unknown node, or node was never enrolled via POST /nodes/enroll")
if not secrets.compare_digest(_hash_secret(x_pickup_secret), node["pickup_secret_hash"]):
raise HTTPException(401, "Invalid pickup secret")
approval_status = node.get("approval_status", "pending")
if approval_status != "approved":
return CredentialsResponse(approval_status=approval_status)
key_doc = await fstore.doc_get("node_keys", node_id)
if not key_doc or not key_doc.get("api_key"):
# Approved but no key yet — shouldn't normally happen, approve_node()
# always writes node_keys in the same call that sets approved. Treat
# it as "keep polling" rather than erroring the node's retry loop.
return CredentialsResponse(approval_status=approval_status)
return CredentialsResponse(approval_status=approval_status, api_key=key_doc["api_key"])