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:
+68
-15
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)]
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user