Files
app/backend/fluksio/api/deps.py
T
Melvin Strobl e0f5ddc3a4
Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Failing after 36s
Playwright Tests / test-playwright (2, 2) (push) Failing after 20s
pre-commit / pre-commit (push) Failing after 2m13s
Test Backend / test-backend (3.12) (push) Successful in 4m32s
Test Backend / test-backend (3.14) (push) Successful in 4m18s
Compose Smoke Test / test-compose (push) Failing after 26s
Playwright Tests / merge-reports (push) Failing after 2m16s
Devices may publish to their own out ports' messages (a level set on the device itself)
2026-10-29 21:58:38 +01:00

618 lines
26 KiB
Python

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
from fastapi.security import OAuth2PasswordBearer
from jwt.exceptions import InvalidTokenError
from pydantic import ValidationError
from sqlmodel import Session, select
from fluksio.cloud import config as cloud_config
from fluksio.cloud import enroll
from fluksio.core import security
from fluksio.core.config import settings
from fluksio.core.db import engine
from fluksio.flow import devices
from fluksio.flow.artifacts import is_reference
from fluksio.flow.controller import FlowController
from fluksio.flow.dashboards import DashboardDef, DashboardStore
from fluksio.flow.devices import DeviceDef, DeviceStore, DeviceUnavailable, presence
from fluksio.flow.events import event_bus
from fluksio.flow.workers import PythonWorkerPool
from fluksio.models import OAuthClient, TokenPayload, User, WorkerToken
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", ""}
#: The one route under a message name a device reads.
_HISTORY = "/history"
def _device_for(payload: dict[str, Any], app: Any) -> DeviceDef:
"""The device a credential names, if that credential still stands.
Two ways it stops standing. The device was deleted, which revokes every
credential ever minted for it, read from disk on each call so it takes
effect at once. Or the device's nonce moved on, which revokes exactly one
screen's and leaves the device and its dashboards alone.
The nonce is held against a credential the portal minted for a remote
screen as well, which carries the nonce this instance named when it asked
for it. One from an older portal carries none and reads as 0, so it
stands only until the device is first unpaired or deleted.
The claim is still called ``panel``: tokens in circulation say it, and
nothing pairs again for a rename.
What cannot be checked right now raises ``DeviceUnavailable`` rather than
refusing: a device told its credential is refused forgets it.
"""
if app is None:
raise InvalidTokenError("Nothing to check a device credential against")
store: DeviceStore | None = getattr(app.state, "device_store", None)
if store is None:
raise DeviceUnavailable("The device store is not running")
device = store.find(str(payload.get("panel", "")))
if device is None:
raise InvalidTokenError("This device no longer exists")
try:
nonce = store.nonce(device.id)
except (OSError, ValueError) as exc:
raise DeviceUnavailable(f"The device nonces are unreadable: {exc}")
if int(payload.get("pnc") or 0) != nonce:
raise InvalidTokenError("This device was paired again")
presence.seen(device.id)
return device
def _device_messages(device: DeviceDef, request: Request) -> set[str]:
"""Every message this device's widgets and ports 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)
owned: DeviceStore | None = getattr(request.app.state, "device_store", None)
if store is None or owned is None:
return set()
return devices.messages_for(device, store, owned)
def _device_in(device: DeviceDef, request: Request) -> set[str]:
"""The messages this device's own ``in`` ports are wired to publish."""
owned: DeviceStore | None = getattr(request.app.state, "device_store", None)
return devices.io_in(device, owned) if owned is not None else set()
def _locked(defn: DashboardDef, live: dict[str, Any]) -> bool:
"""Whether this dashboard is read-only right now.
Read the way the client reads it (``Dashboard/settings.tsx``): the bound
message if it is carrying something, and the stored value otherwise. Live,
because a lock a flow drives is the case the binding exists for — judging
it from the stored fallback alone would refuse every control on a
dashboard its flow has unlocked.
"""
setting = defn.settings.get("locked")
if setting is None:
return False
value = live.get(setting.message) if setting.message else None
return (setting.value if value is None else value) is True
def _device_writable(device: DeviceDef, request: Request) -> set[str]:
"""The messages this device may publish to.
What its dashboards' inputs target, which is narrower than what it may
read: a chart, a reading or a bound setting is something a screen shows,
and showing a message does not entitle it to set one.
A locked dashboard contributes less still: ``locked`` is a dashboard
saying it is there to be looked at, and until this it stopped only the
client drawing the control — the server took the publish from a screen
that asked anyway. Not nothing, though: a querying chart publishes the
request it reads by, and a locked dashboard whose charts cannot ask goes
blank rather than read-only. That request is the exception
``devices.requests_of`` names.
Union, exactly as the allowlist itself is: a message one dashboard on this
panel displays and another controls stays writable, because the unlocked
one is what entitles the screen to it. Reads are untouched — read-only is
not blind, and a locked dashboard has to keep drawing live data.
The device's own ports, as flows wire them, are added outside the lock:
it covers the controls on a dashboard, not the device itself. Its ``in``
ports are its sensors; its ``out`` ports it publishes when set on the
device (a panel's brightness on its config page), so what follows the
message hears of it.
"""
store: DashboardStore | None = getattr(request.app.state, "dashboard_store", None)
if store is None:
return set()
owned: DeviceStore | None = getattr(request.app.state, "device_store", None)
writable = (
{b.message for b in owned.bound(device.id)} if owned is not None else set()
)
defns = devices.dashboards_for(device, store)
bound = sorted(
{s.message for d in defns if (s := d.settings.get("locked")) and s.message}
)
controller: FlowController | None = getattr(
request.app.state, "flow_controller", None
)
live = controller.state.get_present(bound) if bound and controller else {}
for defn in defns:
writable |= (
devices.requests_of(defn)
if _locked(defn, live)
else devices.targets_of(defn)
)
return writable
def _device_digests(device: DeviceDef, request: Request) -> set[str]:
"""The artifacts this panel's messages are pointing at, now or just before.
A media tile fetches the bytes its message names, so the messages already
bounding the panel bound this too — one step further along, through
whatever those messages currently hold. And through the last few they
held: a screen through the portal fetches a camera frame by frame, and by
the time its fetch arrives the camera may have published the next one.
"""
controller: FlowController | None = getattr(
request.app.state, "flow_controller", None
)
return digests_of(controller, _device_messages(device, request))
def digests_of(controller: FlowController | None, names: set[str]) -> set[str]:
"""The artifacts these messages point at now, and the last few before."""
if controller is None or not names:
return set()
found: set[str] = set()
for value in controller.state.get_present(sorted(names)).values():
if is_reference(value):
found.add(str(value["digest"]))
for name in names:
found.update(event_bus.digests.get(name, ()))
return found
def hidden_from(user: User, app: Any, *, device: bool = False) -> frozenset[str]:
"""The camera messages ``user`` may not see (``DeviceDef.viewers``).
None for a superuser, and none for a device credential, which its
dashboards bound instead: a superuser assigned them, camera tiles
included. ``device`` says the caller is one.
"""
if device or user.is_superuser:
return frozenset()
store: DeviceStore | None = getattr(app.state, "device_store", None)
return store.restricted() if store is not None else frozenset()
def _device_may(payload: dict[str, Any], request: Request) -> None:
"""Refuse anything a wall panel has no business asking for.
A panel credential acts as the device itself, an account of no rights
(``device_principal``), and what that account may reach is decided here
and nowhere else. What a panel
genuinely needs is small and worth writing out: the dashboards it was
assigned, its own definition and report, the messages its own widgets and
ports bind to, and the bytes those messages currently point at, for a tile
drawing a camera frame — plus an upload named after one of its ``in``
ports, for a microphone whose recording is the value it publishes.
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 targets. 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. It
reads every message its tiles draw and writes only what its inputs target.
A dashboard that says it is locked is bounded further still — it entitles
a panel to read every message it names and to publish to none of them but
the requests its own charts ask by.
Its own ports are the device nodes the published flows hold for it, so
wiring a port in a flow is what lets the device publish on it.
The device is left on ``request.state`` for the route, which credits a
value published on one of its ports to the node holding that port.
The device file is read per request, which is what makes deleting a
device revoke it at once. The nonces are cached by the file's stat and
the published dashboards until the flow store's next commit.
"""
device = _device_for(payload, request.app)
request.state.device = device
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 device.dashboards and published
elif method == "GET" and path in (
f"{api}/devices/{device.id}",
# What firmware from before devices reads.
f"{api}/panels/{device.id}",
):
allowed = True
elif method == "PUT" and path == f"{api}/devices/{device.id}/report":
# What it has, said about itself and nobody else.
allowed = True
elif method == "POST" and path == f"{api}/flows/ws-ticket":
# How its live socket opens; the socket applies the panel's own scope.
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/") :])
scope = _device_messages
if method == "GET" and rest.endswith(_HISTORY):
name = rest[: -len(_HISTORY)]
elif method == "POST":
name = rest
# A locked dashboard entitles the panel to nothing it can publish.
scope = _device_writable
else:
name = ""
allowed = bool(name) and name in scope(device, request)
elif method == "GET" and path.startswith(f"{api}/artifacts/"):
# The bytes behind a media message a tile on this panel is drawing.
# Scoped to what those messages hold *now*, which is exactly what a
# live widget asks for — a screen has no business reading an artifact
# off an old run because it happens to know the digest.
digest = unquote(path[len(f"{api}/artifacts/") :])
allowed = "/" not in digest and digest in _device_digests(device, request)
elif method == "PUT" and path == f"{api}/artifacts":
# What the device's own sensor recorded — a microphone's utterance —
# uploaded under the ``in`` message it is about to publish it to.
allowed = request.query_params.get("name", "") in _device_in(device, request)
if not allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="A device credential cannot do that",
)
def decode_token(
token: str, request: Request | None = None, app: Any = 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 ``_device_may``
guards. Where there is no request to scope (a socket handshake, the
tunnel), ``app`` is what a device credential is checked against.
An agent's token is held to one thing more: the client it names has to
still be registered, which is what makes revoking an agent take effect now
rather than whenever its stateless token happens to expire.
The last branch is the seam a hosted deployment widens: a portal this
instance was enrolled with signs tokens with a key pinned at
enrolment, and they name the portal account holding them, which resolves
to whichever local account was mapped to it — the superuser who enrolled,
or a remote user one of them admitted since. With no enrolment the branch
raises immediately, so an offline instance 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(
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 not panel_token.get("panel"):
raise InvalidTokenError("a panel token must name its panel")
return _gate_panel(panel_token, request, app)
try:
agent = security.decode_oauth_token(token)
except InvalidTokenError:
return _gate_panel(cloud_config.decode_portal_token(token), request, app)
return _gate_agent(agent)
def oauth_client_lives(client_id: str) -> bool:
"""Is the agent a token names still a registered client? Blocking.
An MCP access token is a stateless JWT good until it expires, so deleting
the client row revoked nothing that had already been handed out. The
client id is a claim the token has always carried, so this holds the ones
already in circulation just as well as the next one minted.
One primary-key read per agent request, against a table with a row per
registered agent. The request it gates goes on to look its user up the
same way, and the device gate next door re-reads the device's file and every
published dashboard from disk, so this is the cheapest check in here.
"""
try:
key = uuid.UUID(client_id)
except ValueError:
return False
with Session(engine) as session:
return session.get(OAuthClient, key) is not None
def worker_claims(token: str) -> dict[str, Any]:
"""A worker credential's claims, if it is still good. Blocking.
Raises ``InvalidTokenError`` for one that does not verify, one minted
before tokens carried a `jti` and their labels, and one a superuser has
revoked. A token whose `jti` has no row is one the engine minted for
itself, a provisioned machine's, and is good until it expires.
"""
claims = security.decode_worker_token(token)
jti = claims.get("jti")
if not jti:
raise InvalidTokenError("this worker token predates revocation; mint a new one")
with Session(engine) as session:
row = session.get(WorkerToken, str(jti))
if row is not None and row.revoked_at is not None:
raise InvalidTokenError("this worker token has been revoked")
return claims
def _gate_agent(payload: dict[str, Any]) -> dict[str, Any]:
"""Refuse a token whose agent has been revoked."""
if not oauth_client_lives(str(payload.get("client_id") or "")):
raise InvalidTokenError("this agent's registration was withdrawn")
return payload
def _gate_panel(
payload: dict[str, Any], request: Request | None, app: Any = None
) -> dict[str, Any]:
"""Scope a payload that names a device; pass anything else through.
Without a request (the websocket, which has no route to scope) the check
is only that the credential still stands, which is what makes deleting a
device, or bumping its nonce, revoke one. Without an app either there is
nothing to check it against, and it is refused.
"""
if not payload.get("panel"):
return payload
if request is not None:
_device_may(payload, request)
else:
_device_for(payload, app)
return payload
#: Where the ids of device principals come from, so each device has its own.
_DEVICE_IDS = uuid.UUID("5e1f2a0c-6d3b-4c1e-9f0a-0d3c1e5b7a20")
def device_principal(device_id: str) -> User:
"""The account a device credential acts as: the device, and no person.
Not stored. A device's credential is checked against the device itself
(``_device_for``) and bounded by route (``_device_may``), so no account's
rights are in play, and deleting or deactivating the superuser who
approved the pairing leaves the device paired. Never a superuser, so a
route gated on one refuses it even past the scope check.
"""
return User(
id=uuid.uuid5(_DEVICE_IDS, device_id),
email=f"{device_id}@device.invalid",
hashed_password="",
full_name=device_id,
is_active=True,
is_superuser=False,
)
def _user_for(session: Session, token_data: TokenPayload) -> User | None:
"""The local account a payload names, by id or by portal identity.
A device credential, already checked by ``decode_token``, names the
device instead, which acts as itself.
A token the portal minted names a person on the portal, not a user here, so
a mapping is what turns one into the other. There are two ways one comes to
exist: a superuser typed their code in, which makes the account up front,
or the owner shared a link from the portal, which admits them there and
leaves this instance to find out when they first arrive. So an unmapped
identity is checked once against the portal's own list of who may reach
this instance, and adopted only if the portal vouches for it.
Deleting the local user stays the whole of the revocation: the portal is
asked rather than the token believed, so a still-valid token cannot rebuild
the account it named, and a person dropped at the portal is not adopted
again.
"""
if token_data.panel:
return device_principal(token_data.panel)
if token_data.portal_sub:
user = session.exec(
select(User).where(User.portal_sub == token_data.portal_sub)
).first()
if user is None:
return enroll.adopt_member(session, token_data.portal_sub)
return user
if not token_data.sub:
return None
try:
# The subject is a string on the wire. Postgres cast it on the way in;
# nothing else does, so parse it here rather than hand a driver a
# string where it wants a UUID.
user_id = uuid.UUID(token_data.sub)
except ValueError:
return None
return session.get(User, user_id)
def user_from_token(
session: Session, token: str, request: Request | None = None, app: Any = None
) -> 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, and
with the artifact endpoint, which accepts a worker's credential as well as
a person's. Pass the request wherever there is one: a device's credential is
scoped by route, and without it the scope check cannot run. Without one,
pass the app, or a device credential is refused.
"""
try:
token_data = TokenPayload(**decode_token(token, request, app))
except (InvalidTokenError, ValidationError):
return None
user = _user_for(session, token_data)
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 = _user_for(session, token_data)
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_hidden(request: Request, user: CurrentUser) -> frozenset[str]:
"""The camera messages the caller of this request may not see."""
device = getattr(request.state, "device", None) is not None
return hidden_from(user, request.app, device=device)
HiddenDep = Annotated[frozenset[str], Depends(get_hidden)]
#: Why a camera's values, bytes or live view are refused.
CAMERA_IS_SUPERUSERS = "Only a superuser may see this camera"
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_device_store(request: Request) -> DeviceStore:
store: DeviceStore | None = getattr(request.app.state, "device_store", None)
if store is None:
raise HTTPException(status_code=503, detail="The flow engine is not running")
return store
DeviceStoreDep = Annotated[DeviceStore, Depends(get_device_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
#: Why a non-superuser is refused a change to what the engine executes.
CODE_IS_SUPERUSERS = "Only a superuser can change code the engine runs"
def get_code_author(current_user: CurrentUser) -> User:
"""A superuser, the only account that may change code the engine runs.
Node code, a command, an installed package: each runs as the engine, with
its signing key and every secret in reach, so whoever writes one holds
the instance whatever their account says.
"""
if not current_user.is_superuser:
raise HTTPException(status_code=403, detail=CODE_IS_SUPERUSERS)
return current_user