From 1253ec155ee0d07d88a653ffd19eb55d9efb587b Mon Sep 17 00:00:00 2001 From: bonamin Date: Wed, 30 Sep 2026 00:02:48 +0300 Subject: [PATCH] feat(mqtt-auth): per-device topic ACL for phone-app users POST /mqtt/auth/acl now handles "app_" users: - topic must be exactly vesper/{serial}// with serial in the user's device_serials (resolved by the users doc `uid` field), - publish (acc 2): only control/command, - subscribe (acc 4) and read/delivery (acc 1): only control/ack, status/heartbeat, status/playback, - wildcard topics (+ / #) are denied, - clientid must start with "app__" so one user cannot reuse another user's client id to kick them off, - blocked users are denied; anything else (incl. other acc values) is 403. Lookups go through mqtt/app_users.py's 60s TTL cache so per-message checks don't hit Firestore every time; assign/unassign/block invalidate it. Also fixes the acc comment: mosquitto passes 1 = read (delivery), 2 = write (publish), 4 = subscribe - not "1 = subscribe, 3 = both". Device/kiosk ACL is unchanged. Co-Authored-By: Claude Opus 5.5 --- backend/mqtt/auth.py | 65 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/backend/mqtt/auth.py b/backend/mqtt/auth.py index 1097cf8..748f813 100644 --- a/backend/mqtt/auth.py +++ b/backend/mqtt/auth.py @@ -161,26 +161,87 @@ def mqtt_auth_user( return Response(status_code=403) +# Mosquitto access values as passed through by go-auth (`acc`). +ACC_READ = 1 # message delivery to the client +ACC_WRITE = 2 # publish +ACC_SUBSCRIBE = 4 # subscribe + +# App-user topic allowlist, relative to vesper/{serial}/ +APP_WRITE_TOPICS = frozenset({"control/command"}) +APP_READ_TOPICS = frozenset({"control/ack", "status/heartbeat", "status/playback"}) + + +def _app_acl_allowed(username: str, clientid: str, topic: str, acc: int) -> tuple[bool, str]: + """ACL decision for "app_". Returns (allowed, reason).""" + uid = username[len(APP_USER_PREFIX):] + if not uid: + return False, "empty uid" + + # Client id must be owned by this user, so one user can't take over + # (and disconnect) another user's session by reusing its client id. + if not clientid.startswith(f"{APP_USER_PREFIX}{uid}_"): + return False, f"clientid {clientid!r} not prefixed with app__" + + if "+" in topic or "#" in topic: + return False, "wildcards not allowed" + + parts = topic.split("/") + if len(parts) != 4 or parts[0] != "vesper" or not parts[1]: + return False, "topic not vesper/{serial}//" + serial, leaf = parts[1], f"{parts[2]}/{parts[3]}" + + if acc == ACC_WRITE: + if leaf not in APP_WRITE_TOPICS: + return False, "publish not allowed on this topic" + elif acc in (ACC_READ, ACC_SUBSCRIBE): + if leaf not in APP_READ_TOPICS: + return False, "read/subscribe not allowed on this topic" + else: + return False, f"unsupported acc={acc}" + + try: + user = app_users.get_app_user(uid) + except Exception as e: + return False, f"user lookup failed ({type(e).__name__})" + if user is None: + return False, "no user doc with this uid" + if user.blocked: + return False, "user is blocked" + if serial not in user.device_serials: + return False, "serial not assigned to user" + + return True, "" + + @router.post("/acl") def mqtt_auth_acl( username: str = Form(...), topic: str = Form(...), clientid: str = Form(default=""), - acc: int = Form(...), # 1 = subscribe, 2 = publish, 3 = subscribe+publish + acc: int = Form(...), # 1 = read (delivery), 2 = write (publish), 4 = subscribe ): """ - Called by Mosquitto on every SUBSCRIBE and PUBLISH. + Called by Mosquitto on every SUBSCRIBE, PUBLISH and message delivery. Returns 200 to allow, 403 to deny. Topic pattern: vesper/{sn}/... - Device users: may only access their own SN segment - Kiosk users: stripped of -kiosk suffix, then same rule applies + - App users (app_): only their assigned serials, only the + command/ack/heartbeat/playback topics — see _app_acl_allowed - Superusers (bonamin, NodeRED): full access """ # Superusers get full access (shouldn't reach here but handled defensively) if username in SUPERUSERS: return Response(status_code=200) + if username.startswith(APP_USER_PREFIX): + allowed, reason = _app_acl_allowed(username, clientid, topic, acc) + if allowed: + return Response(status_code=200) + logger.info("MQTT app ACL denied for %s (acc=%s, topic=%s): %s", username, acc, topic, reason) + return Response(status_code=403) + # Derive the base SN (handles -kiosk suffix) base = _base_sn(username)