Rename the import package app to fluksio
A wheel whose top-level module is `app` collides with anything else in a user's venv, so the package that is about to be published takes the name it is published under. Only the Python package moves; the repo, the Docker WORKDIR and the compose project keep theirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
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, select
|
||||
|
||||
from fluksio.cloud import config as cloud_config
|
||||
from fluksio.core import security
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.core.db import engine
|
||||
from fluksio.flow import panels
|
||||
from fluksio.flow.controller import FlowController
|
||||
from fluksio.flow.dashboards import DashboardStore
|
||||
from fluksio.flow.workers import PythonWorkerPool
|
||||
from fluksio.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.
|
||||
|
||||
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 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 installation 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)
|
||||
try:
|
||||
return security.decode_oauth_token(token)
|
||||
except InvalidTokenError:
|
||||
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_for(session: Session, token_data: TokenPayload) -> User | None:
|
||||
"""The local account a payload names, by id or by portal identity.
|
||||
|
||||
A token the portal minted names a person on the portal, not a user here, so
|
||||
the mapping a superuser made when they admitted them is what turns one into
|
||||
the other. No mapping, no user — the caller answers that the same way it
|
||||
answers a token naming a deleted account, which is what makes deleting the
|
||||
local user the whole of the revocation.
|
||||
"""
|
||||
if token_data.portal_sub:
|
||||
return session.exec(
|
||||
select(User).where(User.portal_sub == token_data.portal_sub)
|
||||
).first()
|
||||
return session.get(User, token_data.sub) if token_data.sub else None
|
||||
|
||||
|
||||
def user_from_token(
|
||||
session: Session, token: str, request: Request | None = 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 panel's credential is
|
||||
scoped by route, and without it the scope check cannot run.
|
||||
"""
|
||||
try:
|
||||
token_data = TokenPayload(**decode_token(token, request))
|
||||
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_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
|
||||
Reference in New Issue
Block a user