A panel writes only what its dashboards' inputs target

It still reads every message its dashboards show. A message a tile only
draws, or a setting is bound to, is no longer writable from the panel.
This commit is contained in:
2026-10-15 10:10:44 +02:00
parent e85b18092f
commit eca3fa0049
4 changed files with 39 additions and 18 deletions
+16 -14
View File
@@ -99,14 +99,17 @@ def _locked(defn: DashboardDef, live: dict[str, Any]) -> bool:
def _panel_writable(panel_id: str, request: Request) -> set[str]:
"""The messages this panel may publish to.
The same walk as the read allowlist, minus whatever a locked dashboard
contributes: ``locked`` is a dashboard saying it is there to be looked at,
and until this it stopped only the client drawing the control — the server
took the publish from a screen that asked anyway.
What its dashboards' inputs target, which is narrower than what it may
read: a chart, a reading or a bound setting is something a screen shows,
and showing a message does not entitle it to set one.
Not quite all of it: a querying chart publishes the request it reads by,
and a locked dashboard whose charts cannot ask goes blank rather than
read-only. That request is the exception ``panels.requests_of`` names.
A locked dashboard contributes less still: ``locked`` is a dashboard
saying it is there to be looked at, and until this it stopped only the
client drawing the control — the server took the publish from a screen
that asked anyway. Not nothing, though: a querying chart publishes the
request it reads by, and a locked dashboard whose charts cannot ask goes
blank rather than read-only. That request is the exception
``panels.requests_of`` names.
Union, exactly as the allowlist itself is: a message one dashboard on this
panel displays and another controls stays writable, because the unlocked
@@ -131,9 +134,7 @@ def _panel_writable(panel_id: str, request: Request) -> set[str]:
live = controller.state.get_present(bound) if bound and controller else {}
for defn in defns:
writable |= (
panels.requests_of(defn)
if _locked(defn, live)
else panels.messages_of(defn)
panels.requests_of(defn) if _locked(defn, live) else panels.targets_of(defn)
)
return writable
@@ -174,11 +175,12 @@ def _panel_may(payload: dict[str, Any], request: Request) -> None:
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
counts as one of its widget's targets. 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. A
dashboard that says it is locked is bounded further still — it entitles a
panel to read every message it names and to publish to none of them but
catalogue behind ``GET /messages/`` is the whole namespace at once. It
reads every message its tiles draw and writes only what its inputs target.
A dashboard that says it is locked is bounded further still — it entitles
a panel to read every message it names and to publish to none of them but
the requests its own charts ask by.
# ponytail: the panel file and the published dashboards are re-read per
+10
View File
@@ -197,6 +197,16 @@ def requests_of(defn: DashboardDef) -> set[str]:
return {w.target for w in defn.widgets if w.type == "chart" and w.target}
def targets_of(defn: DashboardDef) -> set[str]:
"""The messages this dashboard's inputs publish to.
What a screen showing it may write, a narrower set than what it may read.
A tile that only draws a message, and a setting bound to one, are there to
be looked at, so a panel showing them is not entitled to set them.
"""
return {w.target for w in defn.widgets if w.target}
def io_in(panel_id: str) -> set[str]:
"""The messages this panel's own hardware publishes to."""
panel = find(panel_id)
+8 -1
View File
@@ -373,7 +373,8 @@ def test_a_panel_speaks_only_its_own_widgets_messages(
== 404
), target
# And reads the history of everything its widgets bind to.
# And reads the history of everything its widgets bind to, but writes none
# of what they only draw.
for name in (
"demo.temperature",
"demo.switch_state",
@@ -384,6 +385,12 @@ def test_a_panel_speaks_only_its_own_widgets_messages(
client.get(f"{messages}/{name}/history", headers=panel_headers).status_code
== 200
), name
assert (
client.post(
f"{messages}/{name}", headers=panel_headers, json={"value": 1}
).status_code
== 403
), name
# A message no tile on this panel names is not its business, in either
# direction.
+5 -3
View File
@@ -389,9 +389,11 @@ As a superuser:
The screen collects its credential and shows the assigned published dashboards.
Several dashboards get a navigation rail.
A panel credential permits its assigned dashboards, their bound messages and
the artifacts they show. It also permits publishing from its controls and query
charts. On a locked dashboard only the query charts may still publish.
A panel credential reads its assigned dashboards, their bound messages and the
artifacts they show. It writes only the targets of their controls and query
charts, and the `in` ports of its own [I/O](#the-devices-own-io). A message its
tiles only draw, or a dashboard setting is bound to, stays read only. On a
locked dashboard only the query charts may still publish.
**Unpair screen** revokes a locally paired credential and keeps the panel's
dashboard assignments. Deleting the panel removes the assignment and its