Bound a panel credential where the route check cannot reach

Three things the security pass on the portal pairing turned up. The first two
were already true of a screen on the local network; what changed is that a
panel credential is now presentable from the internet, which is what makes
them worth closing rather than recording.

The artifact endpoint authenticates for itself, because a worker's credential
has to open it and that token is no use anywhere else. It resolved the caller
without handing over the request, so the one credential that is scoped by
route was judged by no route at all — a panel could read and write the store
as whoever approved it. It passes the request it already holds now.

The websocket has no route to judge either, and there the bound has to be on
what is sent: a panel is given the values its own dashboards draw and nothing
else — no node status, no logs, no shape of the graph. The keys stay in the
message, emptied, because a screen on a wall runs the bundle it was paired
with. `messages_for` reads that set off the published dashboards, and is the
walk the `/messages/` allowlist has wanted for a while.

And locality is no longer a header anyone can type. The marker the connector
stamps is a value minted per process, so reaching this API directly cannot buy
a device the credential meant for one that cannot reach it at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017F9RnYCJgASuBTcAjxmnsp
This commit is contained in:
2026-08-21 00:09:18 +02:00
co-authored by Claude Opus 5
parent 4c8339e643
commit a8d1b3927e
10 changed files with 252 additions and 16 deletions
+134 -2
View File
@@ -2,6 +2,7 @@
from fastapi.testclient import TestClient
from app.cloud import config as cloud_config
from app.core.config import settings
PREFIX = f"{settings.API_V1_STR}/panels"
@@ -210,6 +211,11 @@ def test_removing_the_panel_revokes_its_credential(
# --------------------------------------------------------------------------
def _via_portal() -> dict[str, str]:
"""The header the connector stamps on a request it replayed off the tunnel."""
return {cloud_config.VIA_HEADER: cloud_config.VIA_PORTAL}
def test_the_device_asking_is_named_before_anyone_approves(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
@@ -269,7 +275,7 @@ def test_a_remote_device_is_paired_at_the_portal(
monkeypatch.setattr(panels_route.httpx, "post", fake_post)
_panels(client, superuser_token_headers, {"panels": [{"id": "hallway"}]})
started = client.post(f"{PREFIX}/pair", headers={"x-fluksio-via": "portal"}).json()
started = client.post(f"{PREFIX}/pair", headers=_via_portal()).json()
looked = client.get(
f"{PREFIX}/pair/{started['code']}/device", headers=superuser_token_headers
@@ -298,7 +304,7 @@ def test_a_remote_device_needs_an_enrolment(
) -> None:
"""Unenrolled, there is nowhere to ask — and no token to invent locally."""
_panels(client, superuser_token_headers, {"panels": [{"id": "shed"}]})
started = client.post(f"{PREFIX}/pair", headers={"x-fluksio-via": "portal"}).json()
started = client.post(f"{PREFIX}/pair", headers=_via_portal()).json()
approved = client.post(
f"{PREFIX}/shed/pair",
@@ -306,3 +312,129 @@ def test_a_remote_device_needs_an_enrolment(
json={"code": started["code"]},
)
assert approved.status_code == 409
def test_a_panel_credential_cannot_move_artifacts(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""The artifact store is a person's or a worker's, not a screen's.
It authenticates for itself rather than through the shared dependency, so
the scope check has to be handed the request there too — without it a panel
would act as whoever approved it.
"""
client.post(f"{DASHBOARDS}/panel_art", headers=superuser_token_headers)
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "hallway", "dashboards": ["panel_art"]}]},
)
panel_headers = _pair(client, superuser_token_headers, "hallway")
assert (
client.put(
f"{settings.API_V1_STR}/artifacts",
headers=panel_headers,
params={"name": "x"},
content=b"hello",
).status_code
== 403
)
def test_a_local_device_cannot_claim_to_be_remote(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""Otherwise it would be handed a credential the whole internet accepts."""
started = client.post(f"{PREFIX}/pair", headers={"x-fluksio-via": "portal"}).json()
looked = client.get(
f"{PREFIX}/pair/{started['code']}/device", headers=superuser_token_headers
)
assert looked.json()["remote"] is False
def test_a_panels_socket_carries_only_what_it_draws(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""A screen may hang anywhere, so it is sent its own values and nothing else.
The handshake has no route for the scope check to judge, so the bound is
applied to what goes out: no node status, no logs, no shape of the graph,
and no value belonging to a dashboard this panel does not show.
"""
from app.api.routes.flows import (
event_for_panel,
panel_scope,
snapshot_payload,
)
client.post(f"{DASHBOARDS}/panel_socket", headers=superuser_token_headers)
saved = client.get(
f"{DASHBOARDS}/panel_socket", headers=superuser_token_headers
).json()
saved["pages"] = [
{
"id": "main",
"title": "Overview",
"sections": [
{
"id": "main",
"widgets": [
{
"id": "w1",
"type": "stat",
"config": {"message": "house.kitchen.temperature"},
}
],
}
],
}
]
written = client.put(
f"{DASHBOARDS}/panel_socket", headers=superuser_token_headers, json=saved
)
assert written.status_code == 200, written.text
# A panel reads the published document, so the draft has to be promoted.
published = client.post(
f"{DASHBOARDS}/panel_socket/publish",
headers=superuser_token_headers,
json={"version": written.json()["version"]},
)
assert published.status_code == 200, published.text
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "hallway", "dashboards": ["panel_socket"]}]},
)
token = _pair(client, superuser_token_headers, "hallway")["Authorization"][7:]
only = panel_scope(token, client.app)
assert only == {"house.kitchen.temperature"}
class _Controller:
def values(self) -> dict[str, dict[str, str]]:
return {
"house.kitchen.temperature": {"value": "21"},
"house.safe.code": {"value": "1234"},
}
payload = snapshot_payload(_Controller(), only) # type: ignore[arg-type]
assert set(payload["values"]) == {"house.kitchen.temperature"}
# Emptied rather than dropped: a hanging screen runs the bundle it was
# paired with, and that bundle reads these keys.
assert payload["logs"] == [] and payload["nodes"] == []
assert payload["issues"] == [] and payload["emits"] == {}
# The stream that follows the snapshot is bounded the same way: a value it
# draws goes out, one it does not stays here, and nothing else travels.
assert event_for_panel(
{"type": "message_value", "name": "house.kitchen.temperature"}, only
)
assert not event_for_panel(
{"type": "message_value", "name": "house.safe.code"}, only
)
assert not event_for_panel({"type": "node_log", "text": "a traceback"}, only)
# A person's credential is not bounded at all.
assert panel_scope(superuser_token_headers["Authorization"][7:], client.app) is None