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 = ""
|
||||
#: 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.
|
||||
self.device = device
|
||||
#: 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.
|
||||
self.remote = remote
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user