Make changing engine-run code superuser-only
Writing node source, installing modules and any flow save that adds, retypes or repoints a code node (Function, Command), edits a Command node's settings or changes a node's resources.env now answer 403 for everyone but a superuser. MCP tools and the assistant reach the same routes, so they inherit the gate. Node types report runs_code. The editor shows code read-only with a note, disables a Command node's settings, leaves code nodes and reusable nodes out of the palette and paste, and the Modules page offers no install.
This commit is contained in:
@@ -444,3 +444,19 @@ def get_current_active_superuser(current_user: CurrentUser) -> User:
|
||||
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
|
||||
|
||||
@@ -19,16 +19,18 @@ from pydantic import BaseModel
|
||||
from sqlmodel import Session, col, select
|
||||
|
||||
from fluksio.api.deps import (
|
||||
CODE_IS_SUPERUSERS,
|
||||
CurrentUser,
|
||||
FlowControllerDep,
|
||||
SessionDep,
|
||||
decode_token,
|
||||
get_code_author,
|
||||
get_current_user,
|
||||
user_from_token,
|
||||
)
|
||||
from fluksio.core.db import engine
|
||||
from fluksio.flow.artifacts import is_reference
|
||||
from fluksio.flow.controller import FlowController
|
||||
from fluksio.flow.controller import NODE_TYPES, FlowController
|
||||
from fluksio.flow.dashboards import DashboardStore
|
||||
from fluksio.flow.events import event_bus
|
||||
from fluksio.flow.messages import flow_of, qualify
|
||||
@@ -46,6 +48,7 @@ from fluksio.flow.schemas import (
|
||||
LibraryNode,
|
||||
MessageHistory,
|
||||
MessageValue,
|
||||
NodeDef,
|
||||
NodeSource,
|
||||
NodeStatusPublic,
|
||||
NodeTypeInfo,
|
||||
@@ -377,11 +380,56 @@ def _check_flavors(definition: FlowDef) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _worker_env(node: NodeDef | None) -> dict[str, str]:
|
||||
return node.resources.env if node is not None and node.resources else {}
|
||||
|
||||
|
||||
def _changes_code(node: NodeDef, old: NodeDef | None) -> bool:
|
||||
"""Whether saving *node* over *old* changes code the engine would run.
|
||||
|
||||
A function node's body has its own route, gated there. What the document
|
||||
carries is which nodes run code at all, which shared source each runs, the
|
||||
command in an exec node's settings, and the environment a node's worker
|
||||
starts with — where `PYTHONPATH` alone is enough to import a file of
|
||||
somebody's choosing.
|
||||
"""
|
||||
if _worker_env(old) != _worker_env(node):
|
||||
return True
|
||||
spec = NODE_TYPES.get(node.type)
|
||||
if spec is None or not spec.runs_code:
|
||||
return False
|
||||
return (
|
||||
old is None
|
||||
or old.type != node.type
|
||||
or old.source_ref != node.source_ref
|
||||
or (not spec.has_source and old.params != node.params)
|
||||
)
|
||||
|
||||
|
||||
def _refuse_code_changes(
|
||||
controller: FlowController, name: str, definition: FlowDef
|
||||
) -> None:
|
||||
"""Refuse a non-superuser a change to code this flow would run.
|
||||
|
||||
Wiring, titles, a function node's settings and every other node stay
|
||||
anyone's.
|
||||
"""
|
||||
before = _read_flow(controller, name) if controller.store.exists(name) else None
|
||||
previous = {node.id: node for node in before.nodes} if before else {}
|
||||
for node in definition.nodes:
|
||||
if _changes_code(node, previous.get(node.id)):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"{CODE_IS_SUPERUSERS} (node '{node.id}')",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{name}", response_model=FlowDetail)
|
||||
async def save_flow(
|
||||
name: str,
|
||||
definition: FlowDef,
|
||||
controller: FlowControllerDep,
|
||||
user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Save unpublished changes to a flow.
|
||||
|
||||
@@ -400,6 +448,8 @@ async def save_flow(
|
||||
raise HTTPException(status_code=400, detail="Node names must be unique")
|
||||
|
||||
_check_flavors(definition)
|
||||
if not user.is_superuser:
|
||||
_refuse_code_changes(controller, name, definition)
|
||||
|
||||
try:
|
||||
stored = await run_in_threadpool(
|
||||
@@ -548,7 +598,11 @@ def read_node_source(
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{name}/nodes/{node_id}/source", response_model=NodeStatusPublic)
|
||||
@router.put(
|
||||
"/{name}/nodes/{node_id}/source",
|
||||
response_model=NodeStatusPublic,
|
||||
dependencies=[Depends(get_code_author)],
|
||||
)
|
||||
async def save_node_source(
|
||||
name: str,
|
||||
node_id: str,
|
||||
|
||||
@@ -16,6 +16,7 @@ from fluksio.api.deps import (
|
||||
CurrentUser,
|
||||
FlowControllerDep,
|
||||
WorkerPoolDep,
|
||||
get_code_author,
|
||||
get_current_user,
|
||||
)
|
||||
from fluksio.flow import modules
|
||||
@@ -34,7 +35,9 @@ async def read_modules(controller: FlowControllerDep) -> Any:
|
||||
return await run_in_threadpool(modules.info, controller.store)
|
||||
|
||||
|
||||
@router.post("/apply", response_model=ApplyResult)
|
||||
@router.post(
|
||||
"/apply", response_model=ApplyResult, dependencies=[Depends(get_code_author)]
|
||||
)
|
||||
async def apply_modules(
|
||||
body: ApplyRequest,
|
||||
controller: FlowControllerDep,
|
||||
@@ -43,6 +46,8 @@ async def apply_modules(
|
||||
) -> Any:
|
||||
"""Install exactly these requirements, then hand them to the workers.
|
||||
|
||||
A superuser's: a package runs its own code on install and on import.
|
||||
|
||||
A manifest that does not resolve changes nothing: the venv is left as it
|
||||
was and the stored manifest is only written once the install succeeded.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user