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 dbeb49f5d3
commit 5369ccb68e
7 changed files with 504 additions and 67 deletions
+242 -1
View File
@@ -154,11 +154,13 @@ def test_paired_panel_reaches_only_what_it_shows(
== 200
)
assert client.get(f"{PREFIX}/hall", headers=panel_headers).status_code == 200
# But not the catalogue, which is the whole namespace at once and no
# screen's business.
assert (
client.get(
f"{settings.API_V1_STR}/messages/", headers=panel_headers
).status_code
== 200
== 403
)
# The generated client spells the default out, so `?draft=false` is what a
@@ -200,6 +202,245 @@ def test_paired_panel_reaches_only_what_it_shows(
assert client.get(f"{PREFIX}/", headers=panel_headers).status_code == 403
#: One of each input widget, a querying chart and a plain reading — the set a
#: seeded example draws on. Between them they name every message a panel is
#: entitled to speak, and nothing names ``demo.unrelated``.
PANEL_WIDGETS = [
{"id": "w_button", "type": "button", "config": {"target": "demo.button"}},
{
"id": "w_switch",
"type": "switch",
"config": {
"target": "demo.switch",
"message": "demo.switch_state",
"dtype": "bool",
},
},
{
"id": "w_slider",
"type": "slider",
"config": {"target": "demo.slider", "dtype": "float"},
},
{"id": "w_input", "type": "input", "config": {"target": "demo.input"}},
{"id": "w_dropdown", "type": "dropdown", "config": {"target": "demo.dropdown"}},
{
"id": "w_query",
"type": "chart",
"config": {
"source": "query",
"request": "demo.query_request",
"request_dtype": "record",
"message": "demo.query_series",
"dtype": "series",
},
},
{"id": "w_stat", "type": "stat", "config": {"message": "demo.temperature"}},
]
def _dashboard_with(
client: TestClient, headers: dict[str, str], name: str, widgets: list[dict]
) -> None:
"""A published dashboard carrying these widgets."""
saved = _dashboard(client, headers, name)
saved["pages"] = [{"id": "main", "sections": [{"id": "main", "widgets": widgets}]}]
written = client.put(f"{DASHBOARDS}/{name}", headers=headers, json=saved)
assert written.status_code == 200, written.text
published = client.post(
f"{DASHBOARDS}/{name}/publish",
headers=headers,
json={"version": written.json()["version"]},
)
assert published.status_code == 200, published.text
def test_a_panel_speaks_only_its_own_widgets_messages(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""The allowlist is a walk of the panel's widgets, not the whole namespace.
404 rather than 200 for the ones it may publish: the gate lets them
through and the engine then refuses them because no flow in this suite
declares them. What matters here is that it is not 403.
"""
_dashboard_with(client, superuser_token_headers, "panel_controls", PANEL_WIDGETS)
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "workshop", "dashboards": ["panel_controls"]}]},
)
panel_headers = _pair(client, superuser_token_headers, "workshop")
messages = f"{settings.API_V1_STR}/messages"
# Every input widget publishes, and so does a querying chart — its request
# is a value it puts into the graph, and a wall panel with no way to send
# it would draw nothing.
for target in (
"demo.button",
"demo.switch",
"demo.slider",
"demo.input",
"demo.dropdown",
"demo.query_request",
):
assert (
client.post(
f"{messages}/{target}", headers=panel_headers, json={"value": 1}
).status_code
== 404
), target
# And reads the history of everything its widgets bind to.
for name in ("demo.temperature", "demo.switch_state", "demo.query_series"):
assert (
client.get(f"{messages}/{name}/history", headers=panel_headers).status_code
== 200
), name
# A message no tile on this panel names is not its business, in either
# direction.
assert (
client.post(
f"{messages}/demo.unrelated", headers=panel_headers, json={"value": 1}
).status_code
== 403
)
assert (
client.get(
f"{messages}/demo.unrelated/history", headers=panel_headers
).status_code
== 403
)
def test_unpairing_a_screen_leaves_its_panel_standing(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""Bumping the nonce revokes one credential and nothing else."""
_dashboard(client, superuser_token_headers, "panel_kept")
assigned = {
"panels": [{"id": "kitchen", "title": "Kitchen", "dashboards": ["panel_kept"]}]
}
_panels(client, superuser_token_headers, assigned)
panel_headers = _pair(client, superuser_token_headers, "kitchen")
assert (
client.get(f"{DASHBOARDS}/panel_kept", headers=panel_headers).status_code == 200
)
bumped = client.post(f"{PREFIX}/kitchen/unpair", headers=superuser_token_headers)
assert bumped.status_code == 200, bumped.text
# 401, not 403: this is no longer a credential, so the device goes back to
# showing a code rather than retrying what it was refused.
assert (
client.get(f"{DASHBOARDS}/panel_kept", headers=panel_headers).status_code == 401
)
# The panel, its title and its assignment are exactly where they were, and
# the next screen pairs to it normally.
panel = client.get(f"{PREFIX}/kitchen", headers=superuser_token_headers).json()
assert panel["title"] == "Kitchen"
assert panel["dashboards"] == ["panel_kept"]
fresh = _pair(client, superuser_token_headers, "kitchen")
assert fresh != panel_headers
assert client.get(f"{DASHBOARDS}/panel_kept", headers=fresh).status_code == 200
# A client holding an older copy of the panels cannot undo the revocation
# by writing the nonce back.
_panels(client, superuser_token_headers, assigned)
assert (
client.get(f"{DASHBOARDS}/panel_kept", headers=panel_headers).status_code == 401
)
assert client.get(f"{DASHBOARDS}/panel_kept", headers=fresh).status_code == 200
assert (
client.post(
f"{PREFIX}/nowhere/unpair", headers=superuser_token_headers
).status_code
== 404
)
assert client.post(f"{PREFIX}/kitchen/unpair").status_code == 401
class _FakeRedis:
"""Just enough Redis for the pending-code store: strings and one zset.
Expiry is not enforced here — each entry carries its own, which is what the
store reads — so this only has to be shared to stand in for two workers.
"""
def __init__(self) -> None:
self.strings: dict[str, str] = {}
self.zset: dict[str, float] = {}
def set(
self, key: str, value: str, ex: int | None = None, nx: bool = False
) -> bool | None:
if nx and key in self.strings:
return None
self.strings[key] = value
return True
def get(self, key: str) -> str | None:
return self.strings.get(key)
def delete(self, key: str) -> None:
self.strings.pop(key, None)
def zadd(self, key: str, mapping: dict[str, float]) -> None:
self.zset.update(mapping)
def zcard(self, key: str) -> int:
return len(self.zset)
def zrem(self, key: str, member: str) -> None:
self.zset.pop(member, None)
def zremrangebyscore(self, key: str, low: str, high: float) -> None:
for member in [m for m, score in self.zset.items() if score <= high]:
del self.zset[member]
def test_a_pending_code_is_found_by_whichever_worker_is_polled() -> None:
"""A poll lands wherever the proxy sent it, not on the minting worker."""
import time
from fluksio.api.routes import panels as panels_route
shared = _FakeRedis()
minted = panels_route._PendingStore()
minted._redis = shared # type: ignore[assignment]
polled = panels_route._PendingStore()
polled._redis = shared # type: ignore[assignment]
entry = panels_route._Pending(
secret="s", expires=time.time() + panels_route.PAIR_TTL, device="a tablet"
)
assert minted.add("ABC123", entry)
# The cap and the collision check mean the same thing on both.
assert not polled.add("ABC123", entry)
assert polled.count() == minted.count() == 1
seen = polled.get("ABC123")
assert seen is not None and seen.device == "a tablet"
# An approval on one worker is collected from the other.
seen.token = "minted-over-there"
polled.save("ABC123", seen)
collected = minted.get("ABC123")
assert collected is not None and collected.token == "minted-over-there"
minted.drop("ABC123")
assert polled.get("ABC123") is None
assert polled.count() == 0
# An entry past its own expiry is nothing, whatever the key still says.
stale = panels_route._Pending(secret="s", expires=time.time() - 1)
assert minted.add("STALE1", stale)
assert polled.get("STALE1") is None
assert polled.count() == 0
def test_removing_the_panel_revokes_its_credential(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None: