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:
+5
-1
@@ -188,7 +188,11 @@ as an em dash.
|
||||
- FEAT/UI: a panel does not notice being reassigned until it is reloaded — nothing pushes the panel document or a dashboard publish, so the rail is as stale as the last read. Same gap as the wallpanel hot-reload item above; one event on the bus would answer both.
|
||||
- CHORE/API: a panel credential may publish *any* message, not only the ones its own widgets bind to — the allowlist is the `/messages/` prefix rather than a walk of the panel's widgets. Enough for a screen in a house; an installation where a panel sits somewhere less trusted would want the narrower check.
|
||||
- CHORE/API: unpairing a device means deleting the panel. A per-panel nonce in the token, bumped on demand, would let one screen be re-paired without disturbing the assignment.
|
||||
- CHORE/API: `POST /panels/pair` is unauthenticated and capped at fifty pending codes in one process. A second API worker would each keep their own dictionary, so pairing would work only when the poll lands on the process that minted the code.
|
||||
- CHORE/API: a panel paired through the portal is revoked here the moment the panel is deleted — `_panel_may` finds nothing and answers 401 — but the hub's copy of the token stays valid until it expires or the installation's generation counter is bumped ("New code"). The hub has no per-panel revocation, and giving it one means telling it which panels exist, which is exactly what this design avoids. The generation bump is the lever; it is blunt, cutting every credential the portal minted for the installation.
|
||||
- CHORE/UI: the device line under a pairing code is the raw user agent plus the address the request came from. Both are self-reported and neither is proof; it is there so an admin can tell the screen they just hung from one they were not expecting, not to authenticate anything.
|
||||
- BUG/UI: the panels dialog is open to any signed-in user, but assigning dashboards is a superuser's (`PUT /panels/`). A non-superuser ticking a checkbox gets a 403, which `main.tsx` treats as a dead session and logs them out. The pairing form is gated on `is_superuser` now; the assignment checkboxes are not.
|
||||
- CHORE/API: `POST /panels/pair` is reachable from the internet once an installation is enrolled — the hub forwards it without a session, since a device with no credential is the point of it. Bounded three ways (the hub's per-installation and per-address limits, and the fifty-code cap here), but it is the first unauthenticated surface this installation exposes outward.
|
||||
- CHORE/API: `POST /panels/pair` is unauthenticated and capped at fifty pending codes in one process. A second API worker would each keep their own dictionary, so pairing would work only when the poll lands on the process that minted the code. The same holds for a screen pairing through the portal, which lands on whichever worker holds the tunnel.
|
||||
- CHORE/UI: the rail draws two letters off the dashboard title. `PageDef` already stores a lucide icon name; a dashboard-level one would read better on a wall.
|
||||
- CHORE/UI: only `layout.lg` is ever written, and `md`/`sm` stay unwritten by decision — a phone stacks the widgets (`.widget-stacked`) rather than carrying an arrangement of its own, since arranging is not a phone feature. The keys stay in the schema for a panel that one day wants a second size.
|
||||
- PERF/UI: `ChartWidget` re-joins the whole table on every live value. Fine at IoT rates; at `HISTORY_CAP` × 5 series it should append into a ring buffer.
|
||||
|
||||
+14
-12
@@ -310,18 +310,20 @@ Shares components with the admin view. See `docs/architecture/structure.canvas`
|
||||
dashboards overview, and the credential that mints is scoped to that
|
||||
panel's dashboards and the message endpoints its widgets speak. Deleting
|
||||
the panel revokes it
|
||||
- [ ] Pair a panel through the portal, for a screen hanging somewhere the
|
||||
installation is not reachable from. Three gates today: the hub's app shell
|
||||
redirects a browser carrying no portal session, its proxy authorizes before
|
||||
forwarding so the unauthenticated pairing call never crosses the tunnel,
|
||||
and the app under a portal takes its bearer from the injected config rather
|
||||
than from storage. A hub token with a `panel` scope opens all three —
|
||||
`decode_portal_token` already branches on scope, and `_panel_may` then
|
||||
applies unchanged, so the installation stays the authority on what the
|
||||
panel reads. The owner check in the hub's `_authorize` is what a panel
|
||||
token must not fall through, and the rate-limit key beside it assumes a
|
||||
user id too. Milestone-level rationale, and the alternative worth weighing
|
||||
first, are in `docs/private/roadmap.md` § M5
|
||||
- [x] Pair a panel through the portal, for a screen hanging somewhere the
|
||||
installation is not reachable from. A fourth hub token class, `scope=panel`,
|
||||
named by panel instead of by person: it passes the hub's `_authorize`
|
||||
without the owner check no panel could satisfy, and arrives here through
|
||||
`decode_portal_token` carrying the panel, where the same `_panel_may` that
|
||||
bounds a locally paired screen bounds it. The installation asks for it
|
||||
holding the credential it dials the tunnel with, so the portal decides
|
||||
nothing but which installation it is for. The three gates opened one each:
|
||||
the shell serves `/panel` alone without a session, the proxy forwards the
|
||||
two pairing calls without one — rate-limited per installation and per
|
||||
address, and stripped of any bearer the browser tried to send — and the
|
||||
credential is traded for the hub's cookie rather than carried in the URL a
|
||||
year-long token must never sit in. What is waiting on a code is named
|
||||
before anyone approves it
|
||||
|
||||
## Phase 5 — Website and docs
|
||||
|
||||
|
||||
+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(
|
||||
|
||||
@@ -121,7 +121,19 @@ def decode_portal_token(token: str) -> dict[str, Any]:
|
||||
audience=config.installation_id,
|
||||
issuer=config.issuer,
|
||||
)
|
||||
if claims.get("scope") != "proxy":
|
||||
scope = claims.get("scope")
|
||||
if scope == "panel":
|
||||
# A screen that paired through the portal. The portal named the panel
|
||||
# and nothing else; what that panel may read is decided here, by the
|
||||
# same check a panel paired on this network passes. It acts as the
|
||||
# enrolling account for want of any other, but the scope check is what
|
||||
# actually bounds it — so a token of this scope that names no panel is
|
||||
# refused rather than left holding the account it borrows.
|
||||
panel = str(claims.get("sub") or "")
|
||||
if not panel:
|
||||
raise InvalidTokenError("a panel token must name its panel")
|
||||
return {"sub": config.local_user_id, "panel": panel}
|
||||
if scope != "proxy":
|
||||
raise InvalidTokenError("not a proxy token")
|
||||
# Every portal session acts as the enrolling local user. Who they are on
|
||||
# the portal is kept for the audit trail, not for authorization.
|
||||
|
||||
@@ -63,6 +63,9 @@ class CloudConnector:
|
||||
"enrolled": config is not None,
|
||||
"connected": self._connected,
|
||||
"portal_url": config.portal_url if config else None,
|
||||
# Where a browser reaches the portal, which is not always where
|
||||
# this process does: enrolment may have named a container.
|
||||
"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": self._last_error,
|
||||
@@ -215,11 +218,18 @@ class CloudConnector:
|
||||
content = base64.b64decode(body) if body else None
|
||||
query = str(frame.get("query") or "")
|
||||
|
||||
headers = dict(frame.get("headers") or {})
|
||||
# Set here rather than trusted from the frame: a browser can send
|
||||
# any header it likes through the proxy, and this one decides where
|
||||
# a pairing device's credential is minted. Arriving on this socket
|
||||
# is the only thing that makes it true.
|
||||
headers["x-fluksio-via"] = "portal"
|
||||
|
||||
async with self._client() as client:
|
||||
request = client.build_request(
|
||||
str(frame.get("method") or "GET"),
|
||||
f"{path}?{query}" if query else path,
|
||||
headers=dict(frame.get("headers") or {}),
|
||||
headers=headers,
|
||||
content=content,
|
||||
)
|
||||
response = await client.send(request, stream=True)
|
||||
|
||||
@@ -203,3 +203,106 @@ def test_removing_the_panel_revokes_its_credential(
|
||||
assert (
|
||||
client.get(f"{DASHBOARDS}/panel_gone", headers=panel_headers).status_code == 401
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# A screen that reached the portal but not this installation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_device_asking_is_named_before_anyone_approves(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
"""Approving a code adopts whatever holds it, so it is worth a look."""
|
||||
started = client.post(
|
||||
f"{PREFIX}/pair",
|
||||
headers={
|
||||
"user-agent": "Mozilla/5.0 (X11; CrOS aarch64)",
|
||||
"x-forwarded-for": "203.0.113.7, 10.0.0.1",
|
||||
},
|
||||
).json()
|
||||
|
||||
looked = client.get(
|
||||
f"{PREFIX}/pair/{started['code']}/device", headers=superuser_token_headers
|
||||
)
|
||||
assert looked.status_code == 200
|
||||
assert "CrOS" in looked.json()["device"]
|
||||
# The first hop, not the proxy that relayed it.
|
||||
assert "203.0.113.7" in looked.json()["device"]
|
||||
assert looked.json()["remote"] is False
|
||||
|
||||
# Nobody without an account gets to enumerate what is waiting.
|
||||
assert client.get(f"{PREFIX}/pair/{started['code']}/device").status_code == 401
|
||||
assert (
|
||||
client.get(
|
||||
f"{PREFIX}/pair/ZZZZZZ/device", headers=superuser_token_headers
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
|
||||
def test_a_remote_device_is_paired_at_the_portal(
|
||||
client: TestClient,
|
||||
superuser_token_headers: dict[str, str],
|
||||
enrolled: object, # noqa: ARG001 (fixture installs the enrolment)
|
||||
monkeypatch, # type: ignore[no-untyped-def]
|
||||
) -> None:
|
||||
"""A device that arrived through the tunnel gets the portal's credential.
|
||||
|
||||
It could never present one this installation signed: the portal verifies
|
||||
what crosses it, and it verifies against its own key.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
from app.api.routes import panels as panels_route
|
||||
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
def fake_post(url: str, **kwargs: object) -> httpx.Response:
|
||||
calls.append({"url": url, **kwargs})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"access_token": "minted-by-the-portal", "expires_in": 31536000},
|
||||
request=httpx.Request("POST", url),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(panels_route.httpx, "post", fake_post)
|
||||
|
||||
_panels(client, superuser_token_headers, {"panels": [{"id": "hallway"}]})
|
||||
started = client.post(f"{PREFIX}/pair", headers={"x-fluksio-via": "portal"}).json()
|
||||
|
||||
looked = client.get(
|
||||
f"{PREFIX}/pair/{started['code']}/device", headers=superuser_token_headers
|
||||
).json()
|
||||
assert looked["remote"] is True
|
||||
|
||||
approved = client.post(
|
||||
f"{PREFIX}/hallway/pair",
|
||||
headers=superuser_token_headers,
|
||||
json={"code": started["code"]},
|
||||
)
|
||||
assert approved.status_code == 200, approved.text
|
||||
|
||||
assert calls[0]["url"].endswith("/api/v1/panel-tokens/") # type: ignore[union-attr]
|
||||
assert calls[0]["headers"]["Authorization"] == "Bearer installation-token" # type: ignore[index]
|
||||
assert calls[0]["json"] == {"panel": "hallway"} # type: ignore[index]
|
||||
|
||||
collected = client.get(
|
||||
f"{PREFIX}/pair/{started['code']}", params={"secret": started["secret"]}
|
||||
).json()
|
||||
assert collected["access_token"] == "minted-by-the-portal"
|
||||
|
||||
|
||||
def test_a_remote_device_needs_an_enrolment(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
"""Unenrolled, there is nowhere to ask — and no token to invent locally."""
|
||||
_panels(client, superuser_token_headers, {"panels": [{"id": "shed"}]})
|
||||
started = client.post(f"{PREFIX}/pair", headers={"x-fluksio-via": "portal"}).json()
|
||||
|
||||
approved = client.post(
|
||||
f"{PREFIX}/shed/pair",
|
||||
headers=superuser_token_headers,
|
||||
json={"code": started["code"]},
|
||||
)
|
||||
assert approved.status_code == 409
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlmodel import Session, SQLModel
|
||||
from sqlmodel import Session, SQLModel, select
|
||||
|
||||
from app.cloud import config as cloud_config
|
||||
from app.core.config import settings
|
||||
from app.core.db import engine, init_db
|
||||
from app.main import app
|
||||
from app.models import User
|
||||
from tests.utils.portal import INSTALLATION_ID, ISSUER, jwks
|
||||
from tests.utils.user import authentication_token_from_email
|
||||
from tests.utils.utils import get_superuser_token_headers
|
||||
|
||||
@@ -66,3 +73,40 @@ def normal_user_token_headers(client: TestClient, db: Session) -> dict[str, str]
|
||||
return authentication_token_from_email(
|
||||
client=client, email=settings.EMAIL_TEST_USER, db=db
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def portal_key() -> rsa.RSAPrivateKey:
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enrolled(
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
portal_key: rsa.RSAPrivateKey,
|
||||
db: Session,
|
||||
) -> Any:
|
||||
"""Enrol this installation with a fake portal, then undo it."""
|
||||
local_user = db.exec(
|
||||
select(User).where(User.email == settings.FIRST_SUPERUSER)
|
||||
).one()
|
||||
original = settings.CLOUD_CONFIG_FILE
|
||||
settings.CLOUD_CONFIG_FILE = (
|
||||
tmp_path_factory.mktemp(f"cloud-{uuid.uuid4().hex[:6]}") / "cloud.json"
|
||||
)
|
||||
cloud_config.save(
|
||||
cloud_config.CloudConfig(
|
||||
portal_url=ISSUER,
|
||||
ws_url=f"{ISSUER}/api/v1/tunnel/attach",
|
||||
installation_id=INSTALLATION_ID,
|
||||
token="installation-token",
|
||||
issuer=ISSUER,
|
||||
jwks=jwks(portal_key),
|
||||
local_user_id=str(local_user.id),
|
||||
enrolled_at=datetime.now(UTC).isoformat(),
|
||||
portal_account=settings.FIRST_SUPERUSER,
|
||||
)
|
||||
)
|
||||
yield local_user
|
||||
cloud_config.delete()
|
||||
settings.CLOUD_CONFIG_FILE = original
|
||||
|
||||
+80
-88
@@ -8,97 +8,20 @@ did.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session, select
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.api.deps import decode_token, user_from_token
|
||||
from app.cloud import config as cloud_config
|
||||
from app.core.config import settings
|
||||
from app.flow.panels import PanelDef, PanelsConfig, write_config
|
||||
from app.models import User
|
||||
|
||||
INSTALLATION_ID = "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f"
|
||||
ISSUER = "https://hub.example.test"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def portal_key() -> rsa.RSAPrivateKey:
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
def _jwks(key: rsa.RSAPrivateKey) -> dict[str, Any]:
|
||||
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(key.public_key()))
|
||||
jwk.update({"use": "sig", "alg": "RS256", "kid": "test-portal"})
|
||||
return {"keys": [jwk]}
|
||||
|
||||
|
||||
def _portal_token(
|
||||
key: rsa.RSAPrivateKey,
|
||||
*,
|
||||
subject: str = "portal-user-1",
|
||||
audience: str = INSTALLATION_ID,
|
||||
issuer: str = ISSUER,
|
||||
scope: str = "proxy",
|
||||
) -> str:
|
||||
now = datetime.now(UTC)
|
||||
pem = key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
return jwt.encode(
|
||||
{
|
||||
"sub": subject,
|
||||
"iss": issuer,
|
||||
"aud": audience,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"scope": scope,
|
||||
},
|
||||
pem,
|
||||
algorithm="RS256",
|
||||
headers={"kid": "test-portal"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enrolled(
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
portal_key: rsa.RSAPrivateKey,
|
||||
db: Session,
|
||||
) -> Any:
|
||||
"""Enrol this installation with a fake portal, then undo it."""
|
||||
local_user = db.exec(
|
||||
select(User).where(User.email == settings.FIRST_SUPERUSER)
|
||||
).one()
|
||||
original = settings.CLOUD_CONFIG_FILE
|
||||
settings.CLOUD_CONFIG_FILE = (
|
||||
tmp_path_factory.mktemp(f"cloud-{uuid.uuid4().hex[:6]}") / "cloud.json"
|
||||
)
|
||||
cloud_config.save(
|
||||
cloud_config.CloudConfig(
|
||||
portal_url=ISSUER,
|
||||
ws_url=f"{ISSUER}/api/v1/tunnel/attach",
|
||||
installation_id=INSTALLATION_ID,
|
||||
token="installation-token",
|
||||
issuer=ISSUER,
|
||||
jwks=_jwks(portal_key),
|
||||
local_user_id=str(local_user.id),
|
||||
enrolled_at=datetime.now(UTC).isoformat(),
|
||||
portal_account=settings.FIRST_SUPERUSER,
|
||||
)
|
||||
)
|
||||
yield local_user
|
||||
cloud_config.delete()
|
||||
settings.CLOUD_CONFIG_FILE = original
|
||||
from tests.utils.portal import ISSUER, portal_token
|
||||
|
||||
|
||||
def test_portal_token_is_refused_when_not_enrolled(
|
||||
@@ -109,7 +32,7 @@ def test_portal_token_is_refused_when_not_enrolled(
|
||||
settings.CLOUD_CONFIG_FILE = tmp_path_factory.mktemp("empty") / "cloud.json"
|
||||
try:
|
||||
with pytest.raises(jwt.exceptions.InvalidTokenError):
|
||||
decode_token(_portal_token(portal_key))
|
||||
decode_token(portal_token(portal_key))
|
||||
finally:
|
||||
settings.CLOUD_CONFIG_FILE = original
|
||||
|
||||
@@ -117,11 +40,11 @@ def test_portal_token_is_refused_when_not_enrolled(
|
||||
def test_portal_token_resolves_to_the_enrolling_user(
|
||||
enrolled: User, portal_key: rsa.RSAPrivateKey, db: Session
|
||||
) -> None:
|
||||
claims = decode_token(_portal_token(portal_key))
|
||||
claims = decode_token(portal_token(portal_key))
|
||||
assert claims["sub"] == str(enrolled.id)
|
||||
# Who they are on the portal is carried for the record, not for rights.
|
||||
assert claims["portal_sub"] == "portal-user-1"
|
||||
assert user_from_token(db, _portal_token(portal_key)) == enrolled
|
||||
assert user_from_token(db, portal_token(portal_key)) == enrolled
|
||||
|
||||
|
||||
def test_token_for_another_installation_is_refused(
|
||||
@@ -130,7 +53,7 @@ def test_token_for_another_installation_is_refused(
|
||||
) -> None:
|
||||
"""The audience is this installation's id, so someone else's is worthless."""
|
||||
with pytest.raises(jwt.exceptions.InvalidTokenError):
|
||||
decode_token(_portal_token(portal_key, audience=str(uuid.uuid4())))
|
||||
decode_token(portal_token(portal_key, audience=str(uuid.uuid4())))
|
||||
|
||||
|
||||
def test_token_from_an_unpinned_key_is_refused(
|
||||
@@ -139,7 +62,7 @@ def test_token_from_an_unpinned_key_is_refused(
|
||||
"""A different portal, or a hijacked one, cannot sign for this installation."""
|
||||
impostor = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
with pytest.raises(jwt.exceptions.InvalidTokenError):
|
||||
decode_token(_portal_token(impostor))
|
||||
decode_token(portal_token(impostor))
|
||||
|
||||
|
||||
def test_non_proxy_scope_is_refused(
|
||||
@@ -147,17 +70,17 @@ def test_non_proxy_scope_is_refused(
|
||||
portal_key: rsa.RSAPrivateKey,
|
||||
) -> None:
|
||||
with pytest.raises(jwt.exceptions.InvalidTokenError):
|
||||
decode_token(_portal_token(portal_key, scope="session"))
|
||||
decode_token(portal_token(portal_key, scope="session"))
|
||||
|
||||
|
||||
def test_disconnecting_ends_remote_access(
|
||||
enrolled: User, portal_key: rsa.RSAPrivateKey
|
||||
) -> None:
|
||||
"""Deleting the config is the whole of the local revocation."""
|
||||
assert decode_token(_portal_token(portal_key))["sub"] == str(enrolled.id)
|
||||
assert decode_token(portal_token(portal_key))["sub"] == str(enrolled.id)
|
||||
cloud_config.delete()
|
||||
with pytest.raises(jwt.exceptions.InvalidTokenError):
|
||||
decode_token(_portal_token(portal_key))
|
||||
decode_token(portal_token(portal_key))
|
||||
|
||||
|
||||
def test_status_reports_not_enrolled(
|
||||
@@ -180,3 +103,72 @@ def test_enrolling_needs_a_superuser(
|
||||
json={"portal_url": ISSUER, "claim_code": "ABCD-EFGH"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_a_panel_scoped_portal_token_reaches_only_its_panel(
|
||||
client: TestClient,
|
||||
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
|
||||
portal_key: rsa.RSAPrivateKey,
|
||||
superuser_token_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""A screen paired through the portal is bounded here, not there.
|
||||
|
||||
The portal names the panel; everything about what that means is this
|
||||
installation's, which is the whole reason it may mint one at all.
|
||||
"""
|
||||
write_config(
|
||||
PanelsConfig(
|
||||
panels=[
|
||||
PanelDef(id="hallway", dashboards=["kitchen"]),
|
||||
PanelDef(id="workshop", dashboards=["bench"]),
|
||||
]
|
||||
)
|
||||
)
|
||||
for name in ("kitchen", "bench"):
|
||||
created = client.post(
|
||||
f"{settings.API_V1_STR}/dashboards/{name}", headers=superuser_token_headers
|
||||
)
|
||||
assert created.status_code in (200, 201, 409), created.text
|
||||
|
||||
token = portal_token(portal_key, subject="hallway", scope="panel")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
assert (
|
||||
client.get(f"{settings.API_V1_STR}/panels/hallway", headers=headers).status_code
|
||||
== 200
|
||||
)
|
||||
assert (
|
||||
client.get(
|
||||
f"{settings.API_V1_STR}/dashboards/kitchen", headers=headers
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
# Another panel's dashboard, the panel list, and a draft are all refused.
|
||||
assert (
|
||||
client.get(
|
||||
f"{settings.API_V1_STR}/dashboards/bench", headers=headers
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
client.get(f"{settings.API_V1_STR}/panels/", headers=headers).status_code == 403
|
||||
)
|
||||
assert (
|
||||
client.get(f"{settings.API_V1_STR}/flows/", headers=headers).status_code == 403
|
||||
)
|
||||
|
||||
# Deleting the panel is how the screen is retired, whoever minted its token.
|
||||
write_config(PanelsConfig(panels=[PanelDef(id="workshop", dashboards=["bench"])]))
|
||||
assert (
|
||||
client.get(f"{settings.API_V1_STR}/panels/hallway", headers=headers).status_code
|
||||
== 401
|
||||
)
|
||||
|
||||
|
||||
def test_a_panel_token_naming_no_panel_is_refused(
|
||||
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
|
||||
portal_key: rsa.RSAPrivateKey,
|
||||
) -> None:
|
||||
"""It would otherwise fall through to the account it borrows."""
|
||||
with pytest.raises(jwt.exceptions.InvalidTokenError):
|
||||
decode_token(portal_token(portal_key, subject="", scope="panel"))
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""A stand-in portal: its key, its JWKS, and the tokens it would mint.
|
||||
|
||||
Shared by the remote-access tests and the panel ones, since a screen paired
|
||||
through a portal is a portal token that happens to name a panel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
INSTALLATION_ID = "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f"
|
||||
ISSUER = "https://hub.example.test"
|
||||
|
||||
|
||||
def jwks(key: rsa.RSAPrivateKey) -> dict[str, Any]:
|
||||
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(key.public_key()))
|
||||
jwk.update({"use": "sig", "alg": "RS256", "kid": "test-portal"})
|
||||
return {"keys": [jwk]}
|
||||
|
||||
|
||||
def portal_token(
|
||||
key: rsa.RSAPrivateKey,
|
||||
*,
|
||||
subject: str = "portal-user-1",
|
||||
audience: str = INSTALLATION_ID,
|
||||
issuer: str = ISSUER,
|
||||
scope: str = "proxy",
|
||||
) -> str:
|
||||
now = datetime.now(UTC)
|
||||
pem = key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
return jwt.encode(
|
||||
{
|
||||
"sub": subject,
|
||||
"iss": issuer,
|
||||
"aud": audience,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"scope": scope,
|
||||
},
|
||||
pem,
|
||||
algorithm="RS256",
|
||||
headers={"kid": "test-portal"},
|
||||
)
|
||||
@@ -2075,9 +2075,29 @@ export const PanelsPublicSchema = {
|
||||
|
||||
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.`
|
||||
} as const;
|
||||
|
||||
export const PendingDeviceSchema = {
|
||||
properties: {
|
||||
device: {
|
||||
type: 'string',
|
||||
title: 'Device'
|
||||
},
|
||||
remote: {
|
||||
type: 'boolean',
|
||||
title: 'Remote',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['device'],
|
||||
title: 'PendingDevice',
|
||||
description: 'Who is asking, as far as the request itself says.'
|
||||
} as const;
|
||||
|
||||
export const PlacementSchema = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { CancelablePromise } from './core/CancelablePromise';
|
||||
import { OpenAPI } from './core/OpenAPI';
|
||||
import { request as __request } from './core/request';
|
||||
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen';
|
||||
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen';
|
||||
|
||||
export class AlertsService {
|
||||
/**
|
||||
@@ -1435,7 +1435,9 @@ export class PanelsService {
|
||||
*
|
||||
* 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.
|
||||
* @returns PairStarted Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
@@ -1475,12 +1477,39 @@ export class PanelsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending Device
|
||||
* 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.
|
||||
* @param data The data for the request.
|
||||
* @param data.code
|
||||
* @returns PendingDevice Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static pendingDevice(data: PanelsPendingDeviceData): CancelablePromise<PanelsPendingDeviceResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/api/v1/panels/pair/{code}/device',
|
||||
path: {
|
||||
code: data.code
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve Pairing
|
||||
* 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.
|
||||
* @param data The data for the request.
|
||||
* @param data.panelId
|
||||
* @param data.requestBody
|
||||
|
||||
@@ -749,15 +749,25 @@ export type PanelsConfig = {
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export type PanelsPublic = {
|
||||
panels?: Array<PanelDef>;
|
||||
frontend_host?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Who is asking, as far as the request itself says.
|
||||
*/
|
||||
export type PendingDevice = {
|
||||
device: string;
|
||||
remote?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a widget sits in its section's grid, in grid units.
|
||||
*/
|
||||
@@ -1424,6 +1434,12 @@ export type PanelsPollPairingData = {
|
||||
|
||||
export type PanelsPollPairingResponse = (PairStatus);
|
||||
|
||||
export type PanelsPendingDeviceData = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
export type PanelsPendingDeviceResponse = (PendingDevice);
|
||||
|
||||
export type PanelsApprovePairingData = {
|
||||
panelId: string;
|
||||
requestBody: PairRequest;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react"
|
||||
|
||||
import {
|
||||
type ApiError,
|
||||
CloudService,
|
||||
type PanelDef,
|
||||
type PanelsConfig,
|
||||
PanelsService,
|
||||
@@ -23,9 +24,13 @@ import {
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import useAuth from "@/hooks/useAuth"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
/** As many characters as a device puts on the wall. */
|
||||
const CODE_LENGTH = 6
|
||||
|
||||
/** The store only accepts this shape, so say so before the request does. */
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
@@ -43,6 +48,23 @@ const slugify = (value: string) =>
|
||||
export function PanelsDialog() {
|
||||
const { data: config } = useQuery(panelsQueryOptions())
|
||||
const { data: dashboards } = useQuery(dashboardsQueryOptions())
|
||||
const { user } = useAuth()
|
||||
// Where a screen that cannot reach this installation pairs instead. The
|
||||
// issuer is the portal as a browser reaches it, which is not always the
|
||||
// address this machine dialled — enrolment may have named a container.
|
||||
const { data: cloud } = useQuery({
|
||||
queryKey: ["cloud", "status"],
|
||||
queryFn: async () =>
|
||||
(await CloudService.readStatus()) as {
|
||||
enrolled: boolean
|
||||
issuer: string | null
|
||||
installation_id: string | null
|
||||
},
|
||||
})
|
||||
const remoteHost =
|
||||
cloud?.enrolled && cloud.issuer && cloud.installation_id
|
||||
? `${cloud.issuer.replace(/\/$/, "")}/i/${cloud.installation_id}`
|
||||
: ""
|
||||
const save = useSavePanels()
|
||||
const { showErrorToast } = useCustomToast()
|
||||
const [name, setName] = useState("")
|
||||
@@ -72,8 +94,9 @@ export function PanelsDialog() {
|
||||
<DialogTitle>Panels</DialogTitle>
|
||||
<DialogDescription>
|
||||
A panel is one screen and the dashboards it shows. Point the device at
|
||||
the link — the installation's own address, reachable from wherever the
|
||||
screen hangs — and it asks for a code you enter here.
|
||||
a link and it asks for a code you enter here — this installation's own
|
||||
address for a screen on your network, or the portal's for one hanging
|
||||
where this machine is not reachable.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -89,6 +112,8 @@ export function PanelsDialog() {
|
||||
key={panel.id}
|
||||
panel={panel}
|
||||
host={host}
|
||||
remoteHost={remoteHost}
|
||||
canPair={Boolean(user?.is_superuser)}
|
||||
dashboards={known.map((dashboard) => ({
|
||||
name: dashboard.name,
|
||||
title: dashboard.title || dashboard.name,
|
||||
@@ -145,6 +170,8 @@ export function PanelsDialog() {
|
||||
function PanelRow({
|
||||
panel,
|
||||
host,
|
||||
remoteHost,
|
||||
canPair,
|
||||
dashboards,
|
||||
onChange,
|
||||
onRemove,
|
||||
@@ -152,6 +179,10 @@ function PanelRow({
|
||||
panel: PanelDef
|
||||
/** Where this installation answers, as it knows itself. */
|
||||
host: string
|
||||
/** Where the portal serves this installation, when it is enrolled. */
|
||||
remoteHost: string
|
||||
/** Approving a code is a superuser's, and so is asking what holds one. */
|
||||
canPair: boolean
|
||||
dashboards: { name: string; title: string }[]
|
||||
onChange: (next: PanelDef) => void
|
||||
onRemove: () => void
|
||||
@@ -159,12 +190,22 @@ function PanelRow({
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
const [code, setCode] = useState("")
|
||||
const assigned = panel.dashboards ?? []
|
||||
const typed = code.trim().toUpperCase()
|
||||
|
||||
// Approving a code adopts whatever is holding it, so say what that is while
|
||||
// there is still time to stop.
|
||||
const { data: waiting } = useQuery({
|
||||
queryKey: ["pending-device", typed],
|
||||
queryFn: () => PanelsService.pendingDevice({ code: typed }),
|
||||
enabled: canPair && typed.length === CODE_LENGTH,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const pair = useMutation({
|
||||
mutationFn: () =>
|
||||
PanelsService.approvePairing({
|
||||
panelId: panel.id,
|
||||
requestBody: { code: code.trim().toUpperCase() },
|
||||
requestBody: { code: typed },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setCode("")
|
||||
@@ -186,6 +227,7 @@ function PanelRow({
|
||||
})
|
||||
|
||||
const link = host ? `${host}/panel/${panel.id}` : ""
|
||||
const remoteLink = remoteHost ? `${remoteHost}/panel` : ""
|
||||
|
||||
return (
|
||||
<div className="grid gap-3" data-testid={`panel-${panel.id}`}>
|
||||
@@ -255,31 +297,60 @@ function PanelRow({
|
||||
className="text-muted-foreground"
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (code.trim()) pair.mutate()
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={code}
|
||||
placeholder="Code shown on the screen"
|
||||
aria-label={`Pairing code for ${panel.id}`}
|
||||
autoComplete="off"
|
||||
maxLength={6}
|
||||
data-testid={`pair-code-${panel.id}`}
|
||||
onChange={(event) => setCode(event.target.value.toUpperCase())}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={!code.trim() || pair.isPending}
|
||||
data-testid={`pair-${panel.id}`}
|
||||
>
|
||||
Pair device
|
||||
</Button>
|
||||
</form>
|
||||
{remoteLink ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={remoteLink}
|
||||
aria-label={`Portal link for ${panel.id}`}
|
||||
className="text-muted-foreground"
|
||||
data-testid={`remote-link-${panel.id}`}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
via portal
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{canPair ? (
|
||||
<>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (typed) pair.mutate()
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={code}
|
||||
placeholder="Code shown on the screen"
|
||||
aria-label={`Pairing code for ${panel.id}`}
|
||||
autoComplete="off"
|
||||
maxLength={CODE_LENGTH}
|
||||
data-testid={`pair-code-${panel.id}`}
|
||||
onChange={(event) => setCode(event.target.value.toUpperCase())}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={!typed || pair.isPending}
|
||||
data-testid={`pair-${panel.id}`}
|
||||
>
|
||||
Pair device
|
||||
</Button>
|
||||
</form>
|
||||
{typed.length === CODE_LENGTH ? (
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
data-testid={`pending-${panel.id}`}
|
||||
>
|
||||
{waiting
|
||||
? `${waiting.device}${waiting.remote ? " · via portal" : ""}`
|
||||
: "No device is waiting on that code."}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -43,6 +43,25 @@ export function apiToken(): string {
|
||||
return portalConfig()?.token ?? localStorage.getItem("access_token") ?? ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand a freshly paired credential to the hub, which keeps it in its cookie.
|
||||
*
|
||||
* Under the portal the page is served with its token injected, so there is
|
||||
* nowhere in this app to put one a device just collected — and a credential
|
||||
* that lasts a year is exactly what must not ride in a query string, where
|
||||
* access logs and browser history would keep it.
|
||||
*/
|
||||
export async function openPortalSession(token: string): Promise<void> {
|
||||
const config = portalConfig()
|
||||
if (!config) return
|
||||
const response = await fetch(`${config.basePath}/session`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
if (!response.ok) throw new Error(`The portal refused the credential`)
|
||||
}
|
||||
|
||||
/**
|
||||
* A path in this app, spelled the way the browser has to spell it.
|
||||
*
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { PanelsService } from "@/client"
|
||||
import { appPath } from "@/lib/portal"
|
||||
import { ApiError, PanelsService } from "@/client"
|
||||
import { appPath, isPortal, openPortalSession } from "@/lib/portal"
|
||||
|
||||
/**
|
||||
* Adopting a screen that has no keyboard.
|
||||
@@ -15,6 +15,11 @@ import { appPath } from "@/lib/portal"
|
||||
*
|
||||
* Outside both shells like `/view/{name}`: until it is paired, this device has
|
||||
* no session and there is nothing to put around it.
|
||||
*
|
||||
* Reached through the portal as well, for a screen hanging where this
|
||||
* installation is not: the hub serves this one page without a session and
|
||||
* forwards these two calls down the tunnel, because a device with no
|
||||
* credential is what they are for.
|
||||
*/
|
||||
export const Route = createFileRoute("/panel/")({
|
||||
component: PairPanel,
|
||||
@@ -24,7 +29,13 @@ export const Route = createFileRoute("/panel/")({
|
||||
function PairPanel() {
|
||||
// A code lives ten minutes. Asking for one is the mount, and asking again is
|
||||
// what happens when this one is no longer recognised.
|
||||
const start = useMutation({ mutationFn: () => PanelsService.startPairing() })
|
||||
const start = useMutation({
|
||||
mutationFn: () => PanelsService.startPairing(),
|
||||
// A screen hung while the link is down has nobody to tell, so it keeps
|
||||
// asking rather than showing six dots until someone power-cycles it.
|
||||
retry: true,
|
||||
retryDelay: 5000,
|
||||
})
|
||||
const request = start.mutate
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: asked for once, when the screen goes up.
|
||||
@@ -47,18 +58,32 @@ function PairPanel() {
|
||||
})
|
||||
|
||||
// Expired, or collected already. Ask for another rather than leaving a
|
||||
// number on the wall that no longer works.
|
||||
// number on the wall that no longer works — but only for that answer: over
|
||||
// the tunnel a 502 or 503 is an ordinary hiccup, and changing the code on
|
||||
// the wall while somebody is typing it is worse than waiting.
|
||||
useEffect(() => {
|
||||
if (error) request()
|
||||
if (error instanceof ApiError && error.status === 404) request()
|
||||
}, [error, request])
|
||||
|
||||
useEffect(() => {
|
||||
if (!status?.access_token || !status.panel) return
|
||||
localStorage.setItem("access_token", status.access_token)
|
||||
// A full load rather than a route change: everything this page asked for
|
||||
// was asked without a credential, and the socket has to dial again holding
|
||||
// this one.
|
||||
window.location.href = appPath(`/panel/${status.panel}`)
|
||||
const token = status.access_token
|
||||
const panel = status.panel
|
||||
const land = () => {
|
||||
// A full load rather than a route change: everything this page asked for
|
||||
// was asked without a credential, and the socket has to dial again
|
||||
// holding this one.
|
||||
window.location.href = appPath(`/panel/${panel}`)
|
||||
}
|
||||
if (isPortal()) {
|
||||
// Under the portal the credential belongs in the hub's own cookie: the
|
||||
// page is served with its config injected, and localStorage is not read
|
||||
// there. Landing anyway if it fails would loop on a page with nothing.
|
||||
openPortalSession(token).then(land).catch(land)
|
||||
return
|
||||
}
|
||||
localStorage.setItem("access_token", token)
|
||||
land()
|
||||
}, [status])
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user