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
+68 -15
View File
@@ -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
+168 -45
View File
@@ -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)]
)