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:
2026-10-06 11:36:30 +02:00
parent 2914d860b1
commit 6802cd2203
19 changed files with 391 additions and 26 deletions
+16
View File
@@ -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
+56 -2
View File
@@ -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,
+6 -1
View File
@@ -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.