From 6d84316ce5ca49bd5108b0ceb73962520d9d3281 Mon Sep 17 00:00:00 2001 From: stroblme Date: Sat, 22 Aug 2026 11:57:37 +0200 Subject: [PATCH] Bound a panel credential to its own widgets, and let one screen be re-paired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu --- backend/fluksio/api/deps.py | 83 ++++++-- backend/fluksio/api/routes/panels.py | 213 ++++++++++++++++----- backend/fluksio/core/security.py | 8 +- backend/fluksio/flow/panels.py | 6 + backend/tests/api/routes/test_panels.py | 243 +++++++++++++++++++++++- docs/code/api.md | 1 + docs/interface/dashboards.md | 17 +- 7 files changed, 504 insertions(+), 67 deletions(-) diff --git a/backend/fluksio/api/deps.py b/backend/fluksio/api/deps.py index c433c29..ceae5d5 100644 --- a/backend/fluksio/api/deps.py +++ b/backend/fluksio/api/deps.py @@ -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 diff --git a/backend/fluksio/api/routes/panels.py b/backend/fluksio/api/routes/panels.py index 2bce03c..33adec9 100644 --- a/backend/fluksio/api/routes/panels.py +++ b/backend/fluksio/api/routes/panels.py @@ -12,7 +12,9 @@ and mints the credential itself when the approval comes. Which side minted it changes nothing about what it may do: the scope check is here either way. The credential is scoped: ``fluksio.api.deps`` lets it reach the dashboards that -panel was assigned and nothing else. Removing the panel revokes it. +panel was assigned, the messages their widgets bind to, and nothing else. +Removing the panel revokes it; so does bumping the panel's nonce, which is how +one screen is re-paired without disturbing what it was showing. """ from __future__ import annotations @@ -20,9 +22,10 @@ from __future__ import annotations import secrets import time from datetime import timedelta -from typing import Any +from typing import Any, cast import httpx +import redis from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.concurrency import run_in_threadpool from pydantic import BaseModel, Field @@ -55,33 +58,112 @@ CODE_LENGTH = 6 MAX_PENDING = 50 -class _Pending: +class _Pending(BaseModel): """A device waiting to be told what it is.""" - def __init__(self, secret_value: str, device: str, remote: bool) -> None: - self.secret = secret_value - self.expires = time.monotonic() + PAIR_TTL - self.token: str = "" - self.panel: str = "" - #: What the request looked like, shown to whoever approves the code so - #: they can tell the screen in the hall from one they were not - #: expecting. Self-reported and worth what that is worth. - self.device = device - #: Whether it came down the tunnel. A device that reached the portal - #: cannot reach this installation, so its credential has to be minted - #: where it can collect it. - self.remote = remote + #: Held by the device, never shown. + secret: str + #: Wall-clock rather than monotonic: an entry outlives the process that + #: minted it, and monotonic clocks are not comparable across processes. + expires: float + token: str = "" + panel: str = "" + #: What the request looked like, shown to whoever approves the code so + #: they can tell the screen in the hall from one they were not expecting. + #: Self-reported and worth what that is worth. + device: str = "" + #: Whether it came down the tunnel. A device that reached the portal + #: cannot reach this installation, so its credential has to be minted + #: where it can collect it. + remote: bool = False -# ponytail: in-process, so pairing needs the API to be one process — which it -# is. Move to a table if it ever runs behind more than one worker. -_pending: dict[str, _Pending] = {} +#: The entry itself, one key per code, expiring on its own. +_PENDING_KEY = "panels:pair" +#: An index of the live codes, so :data:`MAX_PENDING` means the same thing to +#: every worker. Scored by expiry, which is how it is pruned. +_PENDING_INDEX = "panels:pair:__codes__" -def _prune() -> None: - now = time.monotonic() - for code in [c for c, p in _pending.items() if p.expires < now]: - del _pending[code] +class _PendingStore: + """The codes waiting for somebody to say what they are. + + A device's poll lands on whichever API worker the proxy picked, and a + screen pairing through the portal lands on whichever one holds the tunnel + — so a code minted by one worker has to be findable from the next. Redis + is where this installation already keeps what must outlive a process. + + Without one there is one process by definition (the pip install, and the + tests), and a dictionary is the same thing for it. + """ + + def __init__(self) -> None: + self._local: dict[str, _Pending] = {} + self._redis: redis.Redis | None = ( + redis.Redis( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + decode_responses=True, + ) + if settings.REDIS_HOST + else None + ) + + def _key(self, code: str) -> str: + return f"{_PENDING_KEY}:{code}" + + def count(self) -> int: + """How many are waiting, the expired ones dropped first.""" + now = time.time() + if self._redis is None: + for code in [c for c, e in self._local.items() if e.expires < now]: + del self._local[code] + return len(self._local) + self._redis.zremrangebyscore(_PENDING_INDEX, "-inf", now) + return int(cast(int, self._redis.zcard(_PENDING_INDEX))) + + def add(self, code: str, entry: _Pending) -> bool: + """Claim this code, or say that somebody already holds it.""" + if self._redis is None: + if code in self._local: + return False + self._local[code] = entry + return True + if not self._redis.set( + self._key(code), entry.model_dump_json(), ex=PAIR_TTL, nx=True + ): + return False + self._redis.zadd(_PENDING_INDEX, {code: entry.expires}) + return True + + def get(self, code: str) -> _Pending | None: + if self._redis is None: + entry = self._local.get(code) + else: + raw = cast("str | None", self._redis.get(self._key(code))) + entry = _Pending.model_validate_json(raw) if raw else None + return entry if entry is not None and entry.expires >= time.time() else None + + def save(self, code: str, entry: _Pending) -> None: + """Write an approval back, without moving what it expires at.""" + if self._redis is None: + self._local[code] = entry + return + self._redis.set( + self._key(code), + entry.model_dump_json(), + ex=max(1, int(entry.expires - time.time())), + ) + + def drop(self, code: str) -> None: + if self._redis is None: + self._local.pop(code, None) + return + self._redis.delete(self._key(code)) + self._redis.zrem(_PENDING_INDEX, code) + + +_pending = _PendingStore() class PairStarted(BaseModel): @@ -203,8 +285,10 @@ async def save_panels(body: PanelsConfig) -> Any: """Replace the panels. Takes effect on the devices' next read. A panel that disappears here takes its credential with it, so this is also - how a device is unpaired. + how a device is unpaired along with its assignment. Re-pairing one screen + and keeping the assignment is ``/{panel_id}/unpair`` below. """ + stored = {p.id: p.nonce for p in (await run_in_threadpool(read_config)).panels} seen = set() for panel in body.panels: if panel.id in seen: @@ -212,6 +296,10 @@ async def save_panels(body: PanelsConfig) -> Any: status_code=422, detail=f"Two panels named {panel.id!r}" ) seen.add(panel.id) + # The nonce belongs to this installation, not to whoever is writing the + # panels back: a client holding an older copy must not be able to undo + # a revocation by saving an arrangement. + panel.nonce = stored.get(panel.id, 0) await run_in_threadpool(write_config, body) # Which dashboards hang on which panel just changed. An empty name says @@ -226,26 +314,33 @@ def start_pairing(request: Request) -> Any: """A device asks to be adopted. Unauthenticated, by necessity. All this hands out is a code that means nothing until somebody with an - account approves it, so the worst an unwelcome caller achieves is a line in - a dictionary that expires ten minutes later. Reachable from the internet - when this installation is enrolled with a portal, which is what the cap and - the portal's own per-address limits are between. + account approves it, so the worst an unwelcome caller achieves is an entry + that expires ten minutes later. Reachable from the internet when this + installation is enrolled with a portal, which is what the cap and the + portal's own per-address limits are between. """ - _prune() - if len(_pending) >= MAX_PENDING: + if _pending.count() >= MAX_PENDING: raise HTTPException( status_code=429, detail="Too many devices are waiting to be paired" ) - code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH)) - while code in _pending: - code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH)) - remote = secrets.compare_digest( request.headers.get(cloud_config.VIA_HEADER, ""), cloud_config.VIA_PORTAL ) - entry = _Pending(secrets.token_urlsafe(16), _describe(request), remote) - _pending[code] = entry + entry = _Pending( + secret=secrets.token_urlsafe(16), + expires=time.time() + PAIR_TTL, + device=_describe(request), + remote=remote, + ) + # Claiming the code is what settles a collision, rather than looking first: + # between the look and the write sits another worker doing the same thing. + for _ in range(10): + code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH)) + if _pending.add(code, entry): + break + else: + raise HTTPException(status_code=503, detail="Could not mint a pairing code") return PairStarted(code=code, secret=entry.secret) @@ -257,7 +352,6 @@ def poll_pairing(code: str, secret: str = "") -> Any: one polled with the wrong secret: a caller reading a code off a wall learns nothing by guessing. """ - _prune() entry = _pending.get(code) if entry is None or not secrets.compare_digest(entry.secret, secret): raise HTTPException( @@ -266,9 +360,9 @@ def poll_pairing(code: str, secret: str = "") -> Any: if not entry.token: return PairStatus() - # Handed over once. A credential left lying in memory is a second copy of - # it, and the device has the only one it needs. - del _pending[code] + # Handed over once. A credential left lying in the store is a second copy + # of it, and the device has the only one it needs. + _pending.drop(code) return PairStatus(access_token=entry.token, panel=entry.panel) @@ -283,7 +377,6 @@ def pending_device(code: str) -> Any: Approving a code adopts whatever is holding it, so it is worth seeing that it looks like the screen you just hung. """ - _prune() entry = _pending.get(code.strip().upper()) if entry is None: raise HTTPException(status_code=404, detail="No device is waiting on that code") @@ -304,11 +397,12 @@ def approve_pairing(panel_id: str, body: PairRequest, current_user: CurrentUser) account this installation was enrolled with instead, since that is the one every portal-borne request already acts as. """ - _prune() - if find(panel_id) is None: + panel = find(panel_id) + if panel is None: raise HTTPException(status_code=404, detail=f"No panel named {panel_id!r}") - entry = _pending.get(body.code.strip().upper()) + code = body.code.strip().upper() + entry = _pending.get(code) if entry is None: raise HTTPException( status_code=404, @@ -319,12 +413,41 @@ def approve_pairing(panel_id: str, body: PairRequest, current_user: CurrentUser) entry.token = _mint_at_hub(panel_id) else: entry.token = security.create_panel_token( - panel_id, current_user.id, timedelta(days=TOKEN_DAYS) + panel_id, current_user.id, timedelta(days=TOKEN_DAYS), panel.nonce ) entry.panel = panel_id + _pending.save(code, entry) return Message(message=f"Paired {entry.device} with {panel_id}") +@router.post( + "/{panel_id}/unpair", + response_model=Message, + dependencies=[Depends(get_current_active_superuser)], +) +async def unpair_panel(panel_id: str) -> Any: + """Stop honouring this panel's credential, and keep the panel. + + Bumping the nonce refuses the screen hanging there on its next request, so + it goes back to showing a pairing code — while the panel, the dashboards it + was assigned and their arrangement stay exactly as they were. Deleting the + panel is still what throws all three away together. + + A credential the portal minted for a remote screen carries no nonce, so + this does not reach it; that one is revoked at the hub. + """ + config = await run_in_threadpool(read_config) + panel = next((p for p in config.panels if p.id == panel_id), None) + if panel is None: + raise HTTPException(status_code=404, detail=f"No panel named {panel_id!r}") + panel.nonce += 1 + await run_in_threadpool(write_config, config) + # The screen is still holding a socket. One event and it refetches, which + # is where it meets the 401 that sends it back to the pairing code. + event_bus.publish({"type": "dashboard_changed", "dashboard": "", "ts": time.time()}) + return Message(message=f"Unpaired {panel_id}") + + @router.get( "/{panel_id}", response_model=PanelDef, dependencies=[Depends(get_current_user)] ) diff --git a/backend/fluksio/core/security.py b/backend/fluksio/core/security.py index a5b636a..2592ac7 100644 --- a/backend/fluksio/core/security.py +++ b/backend/fluksio/core/security.py @@ -174,7 +174,7 @@ def decode_worker_token(token: str) -> dict[str, Any]: def create_panel_token( - panel: str, user_id: uuid.UUID | str, expires_delta: timedelta + panel: str, user_id: uuid.UUID | str, expires_delta: timedelta, nonce: int = 0 ) -> str: """The credential a paired wall panel holds. @@ -185,6 +185,11 @@ def create_panel_token( ``fluksio.api.deps`` lets it reach only that panel's dashboards and the message endpoints its widgets need. + ``pnc`` is the panel's nonce at the moment of pairing, and the filter + refuses a credential naming any other. Bumping the panel's nonce is + therefore how one screen is re-paired without deleting the panel out from + under its dashboards. + Long-lived on purpose: a wall tablet is set up once and left running, and it has no keyboard to log in again with. """ @@ -193,6 +198,7 @@ def create_panel_token( "sub": str(user_id), "aud": PANEL_AUDIENCE, "panel": panel, + "pnc": nonce, "iat": now, "exp": now + expires_delta, } diff --git a/backend/fluksio/flow/panels.py b/backend/fluksio/flow/panels.py index a4e185e..fc721ed 100644 --- a/backend/fluksio/flow/panels.py +++ b/backend/fluksio/flow/panels.py @@ -30,6 +30,12 @@ class PanelDef(BaseModel): #: rail follows this order. A name that no longer resolves is simply a #: dashboard someone deleted; the panel skips it. dashboards: list[str] = Field(default_factory=list) + #: Which generation of credential this panel honours. A token names the + #: nonce it was minted at, so bumping this refuses the screen currently + #: hanging here and leaves the panel, its dashboards and their arrangement + #: exactly as they are — re-pairing one device without deleting anything. + #: Not settable from outside: a save carries the stored value forward. + nonce: int = 0 @field_validator("id") @classmethod diff --git a/backend/tests/api/routes/test_panels.py b/backend/tests/api/routes/test_panels.py index 74df6a6..e7cbb64 100644 --- a/backend/tests/api/routes/test_panels.py +++ b/backend/tests/api/routes/test_panels.py @@ -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: diff --git a/docs/code/api.md b/docs/code/api.md index 254befe..e5a4f9a 100644 --- a/docs/code/api.md +++ b/docs/code/api.md @@ -146,6 +146,7 @@ code normally reaches these through `fluksio.save_artifact` / | `GET` `PUT` | `/panels/` | which device shows which dashboards | | `POST` | `/panels/pair` | start a pairing | | `GET` | `/panels/pair/{code}` | what is holding a code | +| `POST` | `/panels/{id}/unpair` | drop this panel's credential, keep the panel | ## Secrets, modules, alerts diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 945edba..9ade29a 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -100,13 +100,18 @@ A wall tablet has no keyboard, so it pairs. up within a few seconds and never asks again. What the screen holds is not a login. It reaches that panel's published -dashboards and the message endpoints its widgets speak, and nothing else. +dashboards and the messages its own widgets read or publish, and nothing else — +a message no tile on it draws is refused in both directions. It cannot be made strictly read-only, and that is honest rather than an oversight: a querying chart publishes its request, and a control on a panel is -the reason you put one there. Deleting the panel revokes the credential, which -is also how you retire a device — the screen falls back to asking for a new -code. +the reason you put one there. + +Deleting the panel revokes the credential and the assignment together, which is +how you retire a device and what it showed. Unpairing revokes only the +credential: the panel, its dashboards and their arrangement stay exactly where +they are, and the screen falls back to asking for a new code — which is how you +swap the device out without rebuilding what hangs there. !!! note "If the link is wrong" @@ -128,7 +133,9 @@ The portal names the panel and nothing else. What the panel may read is decided on the installation, on every call, by the same check a locally paired screen passes. Two differences: it acts as the account the installation was enrolled with rather than as whoever approved it, and deleting the panel stops it here -immediately while the portal's copy of the token expires on its own. +immediately while the portal's copy of the token expires on its own — which is +also why unpairing, which works on a credential this installation signed, does +not reach a remote screen. Revoke that one at the hub. ## See also