A panel is one screen and the ordered set of whole dashboards it shows, so a hallway tablet and a workshop tablet carry different sets without either dashboard knowing about the other. More than one and the device draws a rail to switch between them — the same rail the editor puts on screen, because the wall has it and it takes room off the canvas. A screen has no keyboard, so it pairs rather than logs in: it shows a six-character code, somebody approves it against a panel from the dashboards overview, and the credential that mints reaches that panel's published dashboards and the message endpoints its widgets speak, and nothing else. Deleting the panel revokes it. Closes the per-device view and the kiosk credential; supersedes the multi-page/multi-section UI, since a page is now a dashboard of its own.
210 lines
7.9 KiB
Python
210 lines
7.9 KiB
Python
from collections.abc import Generator
|
|
from typing import Annotated, Any
|
|
|
|
import jwt
|
|
from fastapi import Depends, HTTPException, Request, status
|
|
from fastapi.security import OAuth2PasswordBearer
|
|
from jwt.exceptions import InvalidTokenError
|
|
from pydantic import ValidationError
|
|
from sqlmodel import Session
|
|
|
|
from app.cloud import config as cloud_config
|
|
from app.core import security
|
|
from app.core.config import settings
|
|
from app.core.db import engine
|
|
from app.flow import panels
|
|
from app.flow.controller import FlowController
|
|
from app.flow.dashboards import DashboardStore
|
|
from app.flow.workers import PythonWorkerPool
|
|
from app.models import TokenPayload, User
|
|
|
|
reusable_oauth2 = OAuth2PasswordBearer(
|
|
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
|
|
)
|
|
|
|
|
|
def get_db() -> Generator[Session, None, None]:
|
|
with Session(engine) as session:
|
|
yield session
|
|
|
|
|
|
SessionDep = Annotated[Session, Depends(get_db)]
|
|
TokenDep = Annotated[str, Depends(reusable_oauth2)]
|
|
|
|
|
|
#: What a `draft` query parameter has to say to mean "no". Pydantic's own false
|
|
#: literals; anything else is refused rather than guessed at.
|
|
_NOT_DRAFT = {"false", "0", "off", "f", "n", "no", ""}
|
|
|
|
|
|
def _panel_may(payload: dict[str, Any], request: Request) -> None:
|
|
"""Refuse anything a wall panel has no business asking for.
|
|
|
|
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.
|
|
|
|
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.
|
|
"""
|
|
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")
|
|
|
|
api = settings.API_V1_STR
|
|
path = request.url.path
|
|
method = request.method
|
|
|
|
allowed = False
|
|
if method == "GET" and path.startswith(f"{api}/dashboards/"):
|
|
name = path[len(f"{api}/dashboards/") :]
|
|
# One segment only: a dashboard it shows, published — never a draft,
|
|
# and never a sibling route like `/publish`. The flag is read the way
|
|
# the endpoint reads it rather than by presence, because the generated
|
|
# client spells the default out as `?draft=false` — and it is read
|
|
# fail-closed, so a spelling neither side recognises is a draft.
|
|
draft = request.query_params.get("draft")
|
|
published = draft is None or draft.lower() in _NOT_DRAFT
|
|
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
|
|
|
|
if not allowed:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="A panel credential cannot do that",
|
|
)
|
|
|
|
|
|
def decode_token(token: str, request: Request | None = None) -> dict[str, Any]:
|
|
"""Read a bearer token, whichever channel issued it.
|
|
|
|
A browser session is signed with the app's own secret; an agent's token is
|
|
signed with the OAuth keypair, so that set can be revoked on its own and
|
|
the public half published. Both name a user, and both grant that user's
|
|
rights — the difference is only in who is holding it, which the MCP
|
|
endpoint checks separately.
|
|
|
|
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.
|
|
|
|
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.
|
|
"""
|
|
try:
|
|
session: dict[str, Any] = jwt.decode(
|
|
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
|
|
)
|
|
return session
|
|
except InvalidTokenError:
|
|
pass
|
|
try:
|
|
panel_token = security.decode_panel_token(token)
|
|
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
|
|
try:
|
|
return security.decode_oauth_token(token)
|
|
except InvalidTokenError:
|
|
return cloud_config.decode_portal_token(token)
|
|
|
|
|
|
def user_from_token(session: Session, token: str) -> User | None:
|
|
"""Resolve a bearer token to its user, or None if it does not hold up.
|
|
|
|
Shared with the websocket, which cannot use the HTTP security scheme.
|
|
"""
|
|
try:
|
|
token_data = TokenPayload(**decode_token(token))
|
|
except (InvalidTokenError, ValidationError):
|
|
return None
|
|
user = session.get(User, token_data.sub)
|
|
if user is None or not user.is_active:
|
|
return None
|
|
return user
|
|
|
|
|
|
def get_current_user(request: Request, session: SessionDep, token: TokenDep) -> User:
|
|
"""Resolve the bearer token to its user.
|
|
|
|
Every failure here is an authentication failure, so all of them answer 401:
|
|
a token naming a user who no longer exists is a session to log in again,
|
|
not a missing resource to report. A panel credential reaching past what it
|
|
is allowed is the one exception — that is a 403 raised inside
|
|
``decode_token``, and it travels through here untouched.
|
|
"""
|
|
try:
|
|
token_data = TokenPayload(**decode_token(token, request))
|
|
except (InvalidTokenError, ValidationError):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Could not validate credentials",
|
|
)
|
|
user = session.get(User, token_data.sub)
|
|
if user is None or not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="This session is no longer valid — please log in again",
|
|
)
|
|
return user
|
|
|
|
|
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
|
|
|
|
|
def get_flow_controller(request: Request) -> FlowController:
|
|
controller: FlowController | None = getattr(
|
|
request.app.state, "flow_controller", None
|
|
)
|
|
if controller is None:
|
|
raise HTTPException(status_code=503, detail="The flow engine is not running")
|
|
return controller
|
|
|
|
|
|
FlowControllerDep = Annotated[FlowController, Depends(get_flow_controller)]
|
|
|
|
|
|
def get_dashboard_store(request: Request) -> DashboardStore:
|
|
store: DashboardStore | None = getattr(request.app.state, "dashboard_store", None)
|
|
if store is None:
|
|
raise HTTPException(status_code=503, detail="The flow engine is not running")
|
|
return store
|
|
|
|
|
|
DashboardStoreDep = Annotated[DashboardStore, Depends(get_dashboard_store)]
|
|
|
|
|
|
def get_worker_pool(request: Request) -> PythonWorkerPool:
|
|
pool: PythonWorkerPool | None = getattr(request.app.state, "worker_pool", None)
|
|
if pool is None:
|
|
raise HTTPException(status_code=503, detail="The flow engine is not running")
|
|
return pool
|
|
|
|
|
|
WorkerPoolDep = Annotated[PythonWorkerPool, Depends(get_worker_pool)]
|
|
|
|
|
|
def get_current_active_superuser(current_user: CurrentUser) -> User:
|
|
if not current_user.is_superuser:
|
|
raise HTTPException(
|
|
status_code=403, detail="The user doesn't have enough privileges"
|
|
)
|
|
return current_user
|