Files
bonaminandClaude Opus 5.5 79a87e48f1 feat(users): store device_serials on user docs for direct user->device lookup
Adds a `device_serials: [string]` array to Firestore `users` docs so the
MQTT ACL (and get_user_devices) can answer "which boards may this user
reach?" without streaming the entire devices collection.

- The serial is the value used in MQTT topics vesper/{serial}/...: the
  device doc's `serial_number` (flashed into NVS, used by the firmware as
  its MQTT id), falling back to the legacy `device_id` for old docs.
  Centralised in users.service.device_serial_of().
- assign_device / unassign_device now write the device's user_list and the
  user's device_serials (ArrayUnion/ArrayRemove) in one atomic batch.
- The device Manage tab endpoints (POST/DELETE /api/devices/{id}/user-list)
  also edit user_list, so they get the same batched sync - otherwise the
  most common assignment path would silently leave device_serials stale.
- get_user_devices resolves devices via device_serials with chunked
  Firestore "in" queries instead of a full collection scan. Requires the
  backfill script (next commit) to be run for existing assignments.
- New mqtt/app_users.py: resolves users by the `uid` FIELD (not doc id -
  create_user uses .add(), FlutterFlow uses uid as doc id) with a 60s
  in-process TTL cache. Assign/unassign, update, block/unblock and delete
  invalidate that uid's entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:00:49 +03:00

90 lines
2.5 KiB
Python

"""
Firebase app-user lookup for the MQTT auth/ACL endpoints.
App users connect to Mosquitto as "app_<firebase_uid>". Their allowed
devices come from the `device_serials` array on their `users` doc.
User docs are resolved by the `uid` FIELD, never by document ID: docs created
by the Console (users.service.create_user) get random IDs via .add(), while
FlutterFlow uses the Firebase uid as the doc ID.
Per-message ACL checks would otherwise hit Firestore on every publish and
delivery, so lookups are cached in-process for CACHE_TTL_SECONDS. Anything
that changes a user's device_serials or status calls invalidate(uid).
"""
import logging
import threading
import time
from dataclasses import dataclass
from shared.firebase import get_db
logger = logging.getLogger("mqtt.app_users")
USERS_COLLECTION = "users"
CACHE_TTL_SECONDS = 60.0
@dataclass(frozen=True)
class AppUser:
uid: str
blocked: bool
device_serials: frozenset[str]
_cache: dict[str, tuple[float, AppUser | None]] = {}
_lock = threading.Lock()
def _load(uid: str) -> AppUser | None:
db = get_db()
if db is None:
raise RuntimeError("Firestore not initialized")
docs = list(db.collection(USERS_COLLECTION).where("uid", "==", uid).limit(5).stream())
if not docs:
return None
if len(docs) > 1:
# Duplicate profiles for one Firebase account: be conservative on
# blocked, permissive on devices (union), and make it visible.
logger.warning("Multiple user docs share uid=%s: %s", uid, [d.id for d in docs])
blocked = False
serials: set[str] = set()
for doc in docs:
data = doc.to_dict() or {}
if data.get("status") == "blocked":
blocked = True
serials.update(s for s in (data.get("device_serials") or []) if isinstance(s, str) and s)
return AppUser(uid=uid, blocked=blocked, device_serials=frozenset(serials))
def get_app_user(uid: str, *, use_cache: bool = True) -> AppUser | None:
"""Return the app user for a Firebase uid, or None if no user doc has that uid.
use_cache=False forces a Firestore read (and refreshes the cache).
"""
now = time.monotonic()
if use_cache:
with _lock:
hit = _cache.get(uid)
if hit and hit[0] > now:
return hit[1]
user = _load(uid)
with _lock:
_cache[uid] = (now + CACHE_TTL_SECONDS, user)
return user
def invalidate(uid: str) -> None:
with _lock:
_cache.pop(uid, None)
def clear_cache() -> None:
with _lock:
_cache.clear()