User code no longer execs in the engine. A pool of persistent worker subprocesses speaks one JSON object per line; the controller installs a proxy as the node's function, so every execution path funnels through it and the pipeline is untouched. A crash costs one subprocess, a per-node timeout is a kill, and cancelling from the canvas is that same kill. The workers run a venv of the user's own on the data volume, filled from a pip manifest versioned beside the flows. Applying it retires the workers and rebuilds, so a package lands without restarting the engine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
133 lines
4.4 KiB
Python
133 lines
4.4 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.core import security
|
|
from app.core.config import settings
|
|
from app.core.db import engine
|
|
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)]
|
|
|
|
|
|
def decode_token(token: str) -> 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.
|
|
|
|
This is also the seam a hosted deployment widens later: trusting an
|
|
additional issuer is a third branch here, not a change anywhere else.
|
|
"""
|
|
try:
|
|
session: dict[str, Any] = jwt.decode(
|
|
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
|
|
)
|
|
return session
|
|
except InvalidTokenError:
|
|
return security.decode_oauth_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(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.
|
|
"""
|
|
try:
|
|
token_data = TokenPayload(**decode_token(token))
|
|
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
|