Pair a wall panel through the portal
A screen somewhere this installation is not reachable from asks the portal for a code instead, and the portal mints its credential — because a token signed here is one such a device could never present. Where it was minted changes nothing about what it may do. The panel gate moved off the branch that decodes a local panel token and onto whatever claims name a panel, so the portal's and this installation's are bounded by the same check against the same panel's dashboards. A token of that scope naming no panel is refused rather than left holding the account it borrows. The connector marks what arrives on its socket, since that is the only thing that makes it true, and the approval screen now names what is holding a code — approving adopts whatever answers, so it is worth a look first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017F9RnYCJgASuBTcAjxmnsp
This commit is contained in:
+25
-9
@@ -94,14 +94,16 @@ def decode_token(token: str, request: Request | None = None) -> dict[str, Any]:
|
||||
A paired wall panel's token is signed with the app's secret too, and is
|
||||
told apart from a session by its audience: the session decode above
|
||||
refuses it outright, so the only door it fits is the one ``_panel_may``
|
||||
guards. Called without a request — from the websocket, which has no route
|
||||
to scope — the panel branch checks only that the panel still exists.
|
||||
guards.
|
||||
|
||||
The last branch is the seam a hosted deployment widens: a portal this
|
||||
installation was enrolled with signs tokens with a key pinned at
|
||||
enrolment, and they resolve to the local account that performed it. With
|
||||
no enrolment the branch raises immediately, so an offline installation
|
||||
pays nothing for the possibility.
|
||||
pays nothing for the possibility. A screen that paired through that portal
|
||||
arrives there too, naming a panel — which is why the gate below is applied
|
||||
to whichever branch produced the claims rather than to one of them: where a
|
||||
panel credential was minted is not what decides what it may read.
|
||||
"""
|
||||
try:
|
||||
session: dict[str, Any] = jwt.decode(
|
||||
@@ -115,15 +117,29 @@ def decode_token(token: str, request: Request | None = None) -> dict[str, Any]:
|
||||
except InvalidTokenError:
|
||||
pass
|
||||
else:
|
||||
if request is not None:
|
||||
_panel_may(panel_token, request)
|
||||
elif panels.find(str(panel_token.get("panel", ""))) is None:
|
||||
raise InvalidTokenError("This panel no longer exists")
|
||||
return panel_token
|
||||
if not panel_token.get("panel"):
|
||||
raise InvalidTokenError("a panel token must name its panel")
|
||||
return _gate_panel(panel_token, request)
|
||||
try:
|
||||
return security.decode_oauth_token(token)
|
||||
except InvalidTokenError:
|
||||
return cloud_config.decode_portal_token(token)
|
||||
return _gate_panel(cloud_config.decode_portal_token(token), request)
|
||||
|
||||
|
||||
def _gate_panel(payload: dict[str, Any], request: Request | None) -> dict[str, Any]:
|
||||
"""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.
|
||||
"""
|
||||
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")
|
||||
return payload
|
||||
|
||||
|
||||
def user_from_token(session: Session, token: str) -> User | None:
|
||||
|
||||
@@ -45,6 +45,7 @@ def read_status(request: Request) -> dict[str, Any]:
|
||||
"enrolled": config is not None,
|
||||
"connected": False,
|
||||
"portal_url": config.portal_url if config else None,
|
||||
"issuer": config.issuer if config else None,
|
||||
"portal_account": config.portal_account if config else None,
|
||||
"installation_id": config.installation_id if config else None,
|
||||
"last_error": None,
|
||||
|
||||
@@ -5,6 +5,12 @@ shows it on the wall, and somebody with an account types that code into the
|
||||
panels dialog to say which panel the device is. The device polls, collects the
|
||||
credential the approval minted, and never asks again.
|
||||
|
||||
A screen hanging somewhere this installation is not reachable from does the
|
||||
same thing through the portal, which forwards those two calls down the tunnel
|
||||
without a session — a device with no credential is the whole point of them —
|
||||
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: ``app.api.deps`` lets it reach the dashboards that
|
||||
panel was assigned and nothing else. Removing the panel revokes it.
|
||||
"""
|
||||
@@ -16,11 +22,13 @@ import time
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import CurrentUser, get_current_active_superuser, get_current_user
|
||||
from app.cloud import config as cloud_config
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.flow.panels import PanelDef, PanelsConfig, find, read_config, write_config
|
||||
@@ -49,11 +57,19 @@ MAX_PENDING = 50
|
||||
class _Pending:
|
||||
"""A device waiting to be told what it is."""
|
||||
|
||||
def __init__(self, secret_value: str) -> None:
|
||||
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
|
||||
|
||||
|
||||
# ponytail: in-process, so pairing needs the API to be one process — which it
|
||||
@@ -88,14 +104,74 @@ class PairRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
class PendingDevice(BaseModel):
|
||||
"""Who is asking, as far as the request itself says."""
|
||||
|
||||
device: str
|
||||
remote: bool = False
|
||||
|
||||
|
||||
def _describe(request: Request) -> str:
|
||||
"""A line naming the device behind a pairing request.
|
||||
|
||||
# ponytail: the raw user agent, trimmed. Parse it into "iPad · Safari" if
|
||||
# it reads badly on the approval screen.
|
||||
"""
|
||||
agent = request.headers.get("user-agent", "").strip()[:120]
|
||||
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||
# Proxied requests are replayed into this process over an ASGI transport,
|
||||
# which reports every caller as localhost; the forwarded address is the
|
||||
# only true one there, and behind the local reverse proxy it is too.
|
||||
address = forwarded or (request.client.host if request.client else "")
|
||||
return " · ".join(part for part in (agent or "Unknown device", address) if part)
|
||||
|
||||
|
||||
def _mint_at_hub(panel_id: str) -> str:
|
||||
"""Ask the portal for this panel's credential.
|
||||
|
||||
A device that arrived through the portal cannot reach this installation, so
|
||||
a token this installation signed would be one it could never present: the
|
||||
portal verifies what crosses its tunnel, and it verifies against its own
|
||||
key. It mints, we say which panel — and the scope check here decides the
|
||||
rest, on this call and on every later one.
|
||||
"""
|
||||
config = cloud_config.load()
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="That device came through a portal this installation is no "
|
||||
"longer enrolled with",
|
||||
)
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"{config.portal_url.rstrip('/')}/api/v1/panel-tokens/",
|
||||
headers={"Authorization": f"Bearer {config.token}"},
|
||||
json={"panel": panel_id},
|
||||
timeout=15.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502, detail=f"Could not reach the portal: {exc}"
|
||||
) from exc
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"The portal refused to mint a credential ({response.status_code})",
|
||||
)
|
||||
token: str = response.json()["access_token"]
|
||||
return token
|
||||
|
||||
|
||||
class PanelsPublic(BaseModel):
|
||||
"""The panels, and the address a device should be pointed at.
|
||||
|
||||
The address is the server's own, because the browser's origin is not a
|
||||
reliable answer to it: an admin working through the portal is on the
|
||||
portal's origin, and a screen cannot be sent there — the portal serves a
|
||||
page only to someone holding a portal session, and the credential it hands
|
||||
that page is the portal's rather than the panel's.
|
||||
portal's origin, and this one is for a screen on this network.
|
||||
|
||||
An installation enrolled with a portal has a second address, built by the
|
||||
dialog from what ``/cloud/status`` reports rather than from here — a panel
|
||||
is not the thing that knows whether remote access is on.
|
||||
"""
|
||||
|
||||
panels: list[PanelDef] = Field(default_factory=list)
|
||||
@@ -141,12 +217,14 @@ async def save_panels(body: PanelsConfig) -> Any:
|
||||
|
||||
|
||||
@router.post("/pair", response_model=PairStarted)
|
||||
def start_pairing() -> Any:
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
_prune()
|
||||
if len(_pending) >= MAX_PENDING:
|
||||
@@ -158,7 +236,8 @@ def start_pairing() -> Any:
|
||||
while code in _pending:
|
||||
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH))
|
||||
|
||||
entry = _Pending(secrets.token_urlsafe(16))
|
||||
remote = request.headers.get("x-fluksio-via") == "portal"
|
||||
entry = _Pending(secrets.token_urlsafe(16), _describe(request), remote)
|
||||
_pending[code] = entry
|
||||
return PairStarted(code=code, secret=entry.secret)
|
||||
|
||||
@@ -186,6 +265,24 @@ def poll_pairing(code: str, secret: str = "") -> Any:
|
||||
return PairStatus(access_token=entry.token, panel=entry.panel)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/pair/{code}/device",
|
||||
response_model=PendingDevice,
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
)
|
||||
def pending_device(code: str) -> Any:
|
||||
"""What is waiting on this code, before anyone says what it is.
|
||||
|
||||
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")
|
||||
return PendingDevice(device=entry.device, remote=entry.remote)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{panel_id}/pair",
|
||||
response_model=Message,
|
||||
@@ -194,8 +291,11 @@ def poll_pairing(code: str, secret: str = "") -> Any:
|
||||
def approve_pairing(panel_id: str, body: PairRequest, current_user: CurrentUser) -> Any:
|
||||
"""Say which panel the device showing this code is.
|
||||
|
||||
The credential names the approver, so what the panel does stays
|
||||
attributable to a person rather than to nobody.
|
||||
A credential minted here names the approver, so what the panel does stays
|
||||
attributable to a person rather than to nobody. One minted by the portal —
|
||||
for a device that reached this installation only through it — names the
|
||||
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:
|
||||
@@ -208,11 +308,14 @@ def approve_pairing(panel_id: str, body: PairRequest, current_user: CurrentUser)
|
||||
detail="No device is waiting on that code — check it again",
|
||||
)
|
||||
|
||||
entry.token = security.create_panel_token(
|
||||
panel_id, current_user.id, timedelta(days=TOKEN_DAYS)
|
||||
)
|
||||
if entry.remote:
|
||||
entry.token = _mint_at_hub(panel_id)
|
||||
else:
|
||||
entry.token = security.create_panel_token(
|
||||
panel_id, current_user.id, timedelta(days=TOKEN_DAYS)
|
||||
)
|
||||
entry.panel = panel_id
|
||||
return Message(message=f"Paired with {panel_id}")
|
||||
return Message(message=f"Paired {entry.device} with {panel_id}")
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
Reference in New Issue
Block a user