Bound a panel credential to its own widgets, and let one screen be re-paired

Three things a paired wall panel needed.

The scope check now walks the panel's widgets instead of allowing the
`/messages/` prefix wholesale: a screen may publish what its own controls and
querying charts point at, read the history of what its tiles draw, and nothing
else — the catalogue of every message in the installation included. The same
walk that already bounds its socket, so both surfaces agree.

Pending pairing codes moved out of the per-process dictionary into Redis, keyed
per code with the code's own TTL and indexed in a zset so the fifty-code cap
means the same thing to every worker. Without a Redis there is one process by
definition, and the dictionary stays.

And a per-panel nonce in the token, bumped by `POST /panels/{id}/unpair`: that
refuses the screen hanging there without touching the panel, its dashboards or
their arrangement. A save cannot write the nonce back, so a stale client cannot
undo a revocation. Only for a credential this installation signed — one the
portal minted carries no nonce and is revoked at the hub.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
This commit is contained in:
2026-08-22 11:57:37 +02:00
co-authored by Claude Opus 5
parent 16ba11fb07
commit 6d84316ce5
7 changed files with 504 additions and 67 deletions
+68 -15
View File
@@ -1,6 +1,7 @@
import uuid
from collections.abc import Generator
from typing import Annotated, Any
from urllib.parse import unquote
import jwt
from fastapi import Depends, HTTPException, Request, status
@@ -37,6 +38,45 @@ TokenDep = Annotated[str, Depends(reusable_oauth2)]
#: literals; anything else is refused rather than guessed at.
_NOT_DRAFT = {"false", "0", "off", "f", "n", "no", ""}
#: The one route under a message name a panel reads.
_HISTORY = "/history"
def _panel_for(payload: dict[str, Any]) -> panels.PanelDef:
"""The panel a credential names, if that credential still stands.
Two ways it stops standing. The panel was deleted, which revokes every
credential ever minted for it — read from disk on each call, so it takes
effect at once. Or the panel's nonce moved on, which revokes exactly one
screen's and leaves the panel, its dashboards and their arrangement alone.
The nonce is only held against a credential this installation signed. One
the portal minted for a remote screen carries none — the portal names the
panel and nothing else — and is revoked at the hub instead.
"""
panel = panels.find(str(payload.get("panel", "")))
if panel is None:
raise InvalidTokenError("This panel no longer exists")
if (
payload.get("aud") == security.PANEL_AUDIENCE
and int(payload.get("pnc") or 0) != panel.nonce
):
raise InvalidTokenError("This panel was paired with another device")
return panel
def _panel_messages(panel_id: str, request: Request) -> set[str]:
"""Every message this panel's widgets read or write.
The same walk that bounds its socket, so the two surfaces a screen has
agree on what it is entitled to. No store means nothing resolves, which is
the answer to give when the answer cannot be worked out.
"""
store: DashboardStore | None = getattr(request.app.state, "dashboard_store", None)
if store is None:
return set()
return panels.messages_for(panel_id, store)
def _panel_may(payload: dict[str, Any], request: Request) -> None:
"""Refuse anything a wall panel has no business asking for.
@@ -44,17 +84,20 @@ def _panel_may(payload: dict[str, Any], request: Request) -> None:
A panel credential names the account that approved the pairing, so without
this it would be that person's session hanging on a wall. What a panel
genuinely needs is small and worth writing out: the dashboards it was
assigned, its own definition, and the message endpoints its widgets speak.
assigned, its own definition, and the messages its own widgets bind to.
Publishing a message is in the list because a panel cannot be strictly
read-only — a querying chart asks for its window by publishing the request
— and a control on a panel is the point of putting one there.
Publishing is in the list because a panel cannot be strictly read-only — a
control on a panel is the point of putting one there, and a querying chart
asks for its window by publishing a request, which is why that request
counts as one of its widget's messages. Bounded to those, though: a screen
on a wall has no business reaching a message no tile on it draws, and the
catalogue behind ``GET /messages/`` is the whole namespace at once.
# ponytail: the panel file and the published dashboards are re-read per
# request. Cache them behind the store's version if this shows up in a
# profile.
"""
panel = panels.find(str(payload.get("panel", "")))
if panel is None:
# Deleting a panel is how its credential is revoked, so a token naming
# one that is gone is a token to stop trusting.
raise InvalidTokenError("This panel no longer exists")
panel = _panel_for(payload)
api = settings.API_V1_STR
path = request.url.path
@@ -73,8 +116,18 @@ def _panel_may(payload: dict[str, Any], request: Request) -> None:
allowed = "/" not in name and name in panel.dashboards and published
elif method == "GET" and path == f"{api}/panels/{panel.id}":
allowed = True
elif method in ("GET", "POST") and path.startswith(f"{api}/messages/"):
allowed = True
elif path.startswith(f"{api}/messages/"):
# The two a widget speaks: a chart reads a series, a control puts a
# value in. Percent-decoded, because a message name is a path segment
# here and the client encodes it as one.
rest = unquote(path[len(f"{api}/messages/") :])
if method == "GET" and rest.endswith(_HISTORY):
name = rest[: -len(_HISTORY)]
elif method == "POST":
name = rest
else:
name = ""
allowed = bool(name) and name in _panel_messages(panel.id, request)
if not allowed:
raise HTTPException(
@@ -133,15 +186,15 @@ def _gate_panel(payload: dict[str, Any], request: Request | None) -> dict[str, A
"""Scope a payload that names a panel; pass anything else through.
Without a request — from the websocket, which has no route to scope — the
check is only that the panel still exists, which is what makes deleting one
revoke its credential.
check is only that the credential still stands, which is what makes
deleting a panel, or bumping its nonce, revoke one.
"""
if not payload.get("panel"):
return payload
if request is not None:
_panel_may(payload, request)
elif panels.find(str(payload["panel"])) is None:
raise InvalidTokenError("This panel no longer exists")
else:
_panel_for(payload)
return payload