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