feat(mqtt-auth): per-device topic ACL for phone-app users

POST /mqtt/auth/acl now handles "app_<uid>" users:
- topic must be exactly vesper/{serial}/<a>/<b> 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_<uid>_" 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 00:02:48 +03:00
co-authored by Claude Opus 5.5
parent 8a668ca60f
commit 1253ec155e
+63 -2
View File
@@ -161,26 +161,87 @@ def mqtt_auth_user(
return Response(status_code=403) 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_<uid>". 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_<uid>_"
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}/<a>/<b>"
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") @router.post("/acl")
def mqtt_auth_acl( def mqtt_auth_acl(
username: str = Form(...), username: str = Form(...),
topic: str = Form(...), topic: str = Form(...),
clientid: str = Form(default=""), 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. Returns 200 to allow, 403 to deny.
Topic pattern: vesper/{sn}/... Topic pattern: vesper/{sn}/...
- Device users: may only access their own SN segment - Device users: may only access their own SN segment
- Kiosk users: stripped of -kiosk suffix, then same rule applies - Kiosk users: stripped of -kiosk suffix, then same rule applies
- App users (app_<uid>): only their assigned serials, only the
command/ack/heartbeat/playback topics — see _app_acl_allowed
- Superusers (bonamin, NodeRED): full access - Superusers (bonamin, NodeRED): full access
""" """
# Superusers get full access (shouldn't reach here but handled defensively) # Superusers get full access (shouldn't reach here but handled defensively)
if username in SUPERUSERS: if username in SUPERUSERS:
return Response(status_code=200) 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) # Derive the base SN (handles -kiosk suffix)
base = _base_sn(username) base = _base_sn(username)