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:
+68
-15
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user