From 6802cd22032edbced5bbd9d327b07f24be753bb9 Mon Sep 17 00:00:00 2001 From: stroblme Date: Tue, 6 Oct 2026 11:36:30 +0200 Subject: [PATCH] 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. --- backend/fluksio/api/deps.py | 16 ++ backend/fluksio/api/routes/flows.py | 58 +++++++- backend/fluksio/api/routes/modules.py | 7 +- backend/fluksio/flow/controller.py | 6 + backend/fluksio/flow/schemas.py | 4 + backend/tests/api/routes/test_code_rights.py | 138 ++++++++++++++++++ backend/tests/mcp/test_mcp_http.py | 30 ++++ docs/code/api.md | 8 +- docs/code/cli.md | 2 + docs/interface/flow-editor.md | 4 + docs/interface/operations.md | 5 +- docs/reference/node-types.md | 1 + frontend/src/client/schemas.gen.ts | 5 + frontend/src/client/sdk.gen.ts | 2 + frontend/src/client/types.gen.ts | 1 + .../src/components/Flow/CommandPalette.tsx | 9 +- frontend/src/components/Flow/FlowEditor.tsx | 47 +++++- frontend/src/components/Flow/NodePanel.tsx | 61 ++++++-- frontend/src/routes/_layout/modules.tsx | 13 +- 19 files changed, 391 insertions(+), 26 deletions(-) create mode 100644 backend/tests/api/routes/test_code_rights.py diff --git a/backend/fluksio/api/deps.py b/backend/fluksio/api/deps.py index 069e539..706c099 100644 --- a/backend/fluksio/api/deps.py +++ b/backend/fluksio/api/deps.py @@ -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 diff --git a/backend/fluksio/api/routes/flows.py b/backend/fluksio/api/routes/flows.py index c737b17..ed992ca 100644 --- a/backend/fluksio/api/routes/flows.py +++ b/backend/fluksio/api/routes/flows.py @@ -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, diff --git a/backend/fluksio/api/routes/modules.py b/backend/fluksio/api/routes/modules.py index 31e4e67..0d4b40d 100644 --- a/backend/fluksio/api/routes/modules.py +++ b/backend/fluksio/api/routes/modules.py @@ -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. diff --git a/backend/fluksio/flow/controller.py b/backend/fluksio/flow/controller.py index 675c26f..393140c 100644 --- a/backend/fluksio/flow/controller.py +++ b/backend/fluksio/flow/controller.py @@ -303,6 +303,9 @@ class NodeType: # Constructor signatures differ per node type. cls: Any has_source: bool = False + #: Runs code its definition supplies — a source file, or a command among + #: its settings — with everything the engine can reach. + runs_code: bool = False free_params: bool = False params_schema: dict[str, Any] = field(default_factory=dict) #: Which installed package supplied this type, for the ones that are not @@ -321,6 +324,7 @@ NODE_TYPES: dict[str, NodeType] = { description="Your own Python code, run on every incoming message.", cls=Node, has_source=True, + runs_code=True, free_params=True, ), "mqtt": NodeType( @@ -399,6 +403,7 @@ NODE_TYPES: dict[str, NodeType] = { title="Command", description="Run a command in the engine's container and read its output.", cls=ExecNode, + runs_code=True, params_schema=_schema_of(ExecNode), ), "file": NodeType( @@ -424,6 +429,7 @@ def node_type_info() -> list[NodeTypeInfo]: description=spec.description, params_schema=spec.params_schema, has_source=spec.has_source, + runs_code=spec.runs_code, free_params=spec.free_params, plugin=spec.plugin, ) diff --git a/backend/fluksio/flow/schemas.py b/backend/fluksio/flow/schemas.py index 40f04c1..83fc278 100644 --- a/backend/fluksio/flow/schemas.py +++ b/backend/fluksio/flow/schemas.py @@ -476,6 +476,10 @@ class NodeTypeInfo(BaseModel): description: str params_schema: dict[str, Any] = Field(default_factory=dict) has_source: bool = False + #: Whether a node of this type runs code its definition supplies: its + #: source, or a command among its settings. Only a superuser may add one + #: or change that code. + runs_code: bool = False #: Whether this type takes settings beyond the ones its schema declares. #: A function node's settings are its author's to name, and reach `process` #: as keyword arguments beside its ports. diff --git a/backend/tests/api/routes/test_code_rights.py b/backend/tests/api/routes/test_code_rights.py new file mode 100644 index 0000000..04c8f91 --- /dev/null +++ b/backend/tests/api/routes/test_code_rights.py @@ -0,0 +1,138 @@ +"""Code the engine runs is a superuser's to change; everything else is not.""" + +from collections.abc import Callable +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from fluksio.api.deps import CODE_IS_SUPERUSERS +from fluksio.api.routes import modules as modules_route +from fluksio.core.config import settings + +PREFIX = f"{settings.API_V1_STR}/flows" +FLOW = "code_rights" + + +def a_flow(version: int, **changes: Any) -> dict[str, Any]: + nodes: dict[str, dict[str, Any]] = { + "fn": { + "id": "fn", + "type": "python", + "params": {"gain": 1}, + "provides": [{"name": "reading", "dtype": "float"}], + }, + "cmd": {"id": "cmd", "type": "exec", "params": {"command": "echo hi"}}, + "hold": {"id": "hold", "type": "delay", "params": {"delay": 1}}, + } + for node_id, patch in changes.items(): + nodes[node_id] = {**nodes.get(node_id, {"id": node_id}), **patch} + return {"name": FLOW, "version": version, "nodes": list(nodes.values())} + + +@pytest.fixture +def save( + client: TestClient, superuser_token_headers: dict[str, str] +) -> Callable[..., Any]: + """Seed the flow as a superuser, then save a change as someone else.""" + client.delete(f"{PREFIX}/{FLOW}", headers=superuser_token_headers) + seeded = client.put( + f"{PREFIX}/{FLOW}", headers=superuser_token_headers, json=a_flow(0) + ) + assert seeded.status_code == 200, seeded.text + version = seeded.json()["definition"]["version"] + + def put(headers: dict[str, str], **changes: Any) -> Any: + return client.put( + f"{PREFIX}/{FLOW}", headers=headers, json=a_flow(version, **changes) + ) + + return put + + +@pytest.mark.parametrize( + "changes", + [ + {"new": {"type": "python"}}, + {"new": {"type": "exec", "params": {"command": "id"}}}, + {"cmd": {"params": {"command": "cat /proc/self/environ"}}}, + {"hold": {"type": "exec", "params": {"command": "id"}}}, + {"fn": {"source_ref": "someone_elses"}}, + {"fn": {"resources": {"env": {"PYTHONPATH": "/data/files"}}}}, + ], + ids=["add-function", "add-exec", "exec-command", "retype", "repoint", "env"], +) +def test_a_user_cannot_change_code_in_a_flow( + save: Callable[..., Any], + normal_user_token_headers: dict[str, str], + superuser_token_headers: dict[str, str], + changes: dict[str, Any], +) -> None: + refused = save(normal_user_token_headers, **changes) + assert refused.status_code == 403 + assert refused.json()["detail"].startswith(CODE_IS_SUPERUSERS) + assert save(superuser_token_headers, **changes).status_code == 200 + + +def test_a_user_keeps_everything_but_the_code( + save: Callable[..., Any], normal_user_token_headers: dict[str, str] +) -> None: + saved = save( + normal_user_token_headers, + fn={ + "title": "Renamed", + "params": {"gain": 2}, + "requires": [{"name": "trigger", "dtype": "float"}], + }, + hold={"params": {"delay": 5}}, + late={"type": "delay", "params": {"delay": 2}}, + ) + assert saved.status_code == 200, saved.text + + +@pytest.mark.usefixtures("save") +def test_node_source_is_a_superusers_to_write( + client: TestClient, + normal_user_token_headers: dict[str, str], + superuser_token_headers: dict[str, str], +) -> None: + url = f"{PREFIX}/{FLOW}/nodes/fn/source" + body = {"code": "def process():\n return {'reading': 1.0}\n"} + + refused = client.put(url, headers=normal_user_token_headers, json=body) + assert refused.status_code == 403 + assert refused.json()["detail"] == CODE_IS_SUPERUSERS + # Reading it is still anyone's. + assert client.get(url, headers=normal_user_token_headers).status_code == 200 + + saved = client.put(url, headers=superuser_token_headers, json=body) + assert saved.status_code == 200, saved.text + + +def test_installing_modules_is_a_superusers( + client: TestClient, + monkeypatch: pytest.MonkeyPatch, + normal_user_token_headers: dict[str, str], + superuser_token_headers: dict[str, str], +) -> None: + installs: list[str] = [] + + def sync(requirements: str) -> tuple[bool, str]: + installs.append(requirements) + return True, "" + + async def retire(*_: Any) -> None: + return None + + monkeypatch.setattr(modules_route.modules, "sync", sync) + monkeypatch.setattr(modules_route, "_retire", retire) + url = f"{settings.API_V1_STR}/modules/apply" + body = {"requirements": ""} + + refused = client.post(url, headers=normal_user_token_headers, json=body) + assert refused.status_code == 403 + assert refused.json()["detail"] == CODE_IS_SUPERUSERS + assert installs == [] + + assert client.post(url, headers=superuser_token_headers, json=body).is_success + assert installs == [""] diff --git a/backend/tests/mcp/test_mcp_http.py b/backend/tests/mcp/test_mcp_http.py index 8a6f89a..6d04e49 100644 --- a/backend/tests/mcp/test_mcp_http.py +++ b/backend/tests/mcp/test_mcp_http.py @@ -1,6 +1,7 @@ """The MCP endpoint: who it lets in, and what happens once they are in.""" import asyncio +import json import uuid from collections.abc import Awaitable, Callable, Generator from datetime import timedelta @@ -11,6 +12,7 @@ import pytest from fastapi.testclient import TestClient from sqlmodel import Session +from fluksio.api.deps import CODE_IS_SUPERUSERS from fluksio.core import security from fluksio.core.config import settings from fluksio.main import app @@ -170,3 +172,31 @@ def test_revoking_an_agent_stops_the_token_it_already_holds( before, after = over_mcp(block) assert before.status_code == 200 assert after.status_code == 401 + + +def test_an_agent_cannot_write_code_its_person_may_not( + over_mcp, + client: TestClient, + normal_user_token_headers: dict[str, str], + agent: uuid.UUID, +) -> None: + """The tools call the same routes, so an agent inherits the code gate.""" + me = client.get( + f"{settings.API_V1_STR}/users/me", headers=normal_user_token_headers + ).json() + assert me["is_superuser"] is False + token = mcp_token(uuid.UUID(me["id"]), agent) + + async def block(http: httpx.AsyncClient) -> httpx.Response: + return await http.post( + "/mcp", + **rpc( + token, + "tools/call", + name="save_node_source", + arguments={"name": "any", "node_id": "fn", "code": "import os"}, + ), + ) + + answer = json.loads(over_mcp(block).json()["result"]["content"][0]["text"]) + assert answer == {"error": CODE_IS_SUPERUSERS, "status": 403} diff --git a/docs/code/api.md b/docs/code/api.md index fff708e..ddd913a 100644 --- a/docs/code/api.md +++ b/docs/code/api.md @@ -62,6 +62,12 @@ curl -X PUT $FLUKSIO/flows/house/nodes/decide/source -H "Authorization: Bearer $ -H 'Content-Type: application/json' -d '{"code": "def process(x):\n return {\"y\": x * 2}\n"}' ``` +Writing a source is a superuser's, and so is any `PUT /flows/{name}` that +adds a Function or Command node, retypes or repoints one, changes a Command +node's settings, or changes any node's `resources.env`: each of them is code +the engine runs. Anyone else gets a 403 and keeps the rest — wiring, titles, +a Function node's settings and every other node. + `POST .../share` promotes a node's source to the shared library; `POST .../unshare` gives it a private copy back. `GET /flows/library` lists the shared sources and which flows use each. @@ -197,7 +203,7 @@ curl -X POST $FLUKSIO/alerts/test/my-phone -H "Authorization: Bearer $TOKEN" ``` Secrets are write-only over the API: you can list names and set values, never -read one back. +read one back. Secrets and `POST /modules/apply` are a superuser's. ## Observability diff --git a/docs/code/cli.md b/docs/code/cli.md index b9aa30b..f0fd45b 100644 --- a/docs/code/cli.md +++ b/docs/code/cli.md @@ -297,6 +297,8 @@ helper a node calls into is reported as that node changing (`train: updated (flow, fit)`) and re-runs it, while editing something the node never reaches is left alone. +Uploading node code takes a superuser's token; anyone else's sync is refused. + A sync that changed nothing says `unchanged`, which is the answer worth having. `published` and `draft` are said only when there was something to publish or a draft was genuinely left behind. An engine too old to store what diff --git a/docs/interface/flow-editor.md b/docs/interface/flow-editor.md index 14b2828..2a6c178 100644 --- a/docs/interface/flow-editor.md +++ b/docs/interface/flow-editor.md @@ -80,6 +80,10 @@ reloads without the flow stopping. A node file defines `process(...)`; if it defines exactly one public function under another name, that one is used instead. +Code runs as the engine, so changing it is a superuser's. Anyone else sees the +editor read-only, a Command node's settings disabled, and no Function, Command +or reusable nodes to add. + **Shared** turns a node's code into a library entry other flows can reuse. Editing a shared node edits the copy every flow using it runs, which is the point and also the caution. diff --git a/docs/interface/operations.md b/docs/interface/operations.md index b08a1c2..9ca5dbb 100644 --- a/docs/interface/operations.md +++ b/docs/interface/operations.md @@ -39,7 +39,8 @@ Node code runs in a virtual environment of its own, on the instance's data volume, separate from the packages Fluksio itself runs on. A pin of yours can never shadow one of ours, and vice versa. -**Modules** is a pip manifest and an **Apply** button. It is installed with +**Modules** is a pip manifest and an **Apply** button, which only a superuser +sees: a package runs its own code on install. It is installed with `uv pip sync`, so a line you delete is uninstalled rather than left behind. The manifest is versioned alongside your flows, so what a deployment installed is recorded with what uses it. @@ -114,7 +115,7 @@ is a superuser. Accounts that arrived through a portal are badged *Portal*. They have no password (they sign in at the portal, not here) and they are never -superusers. Deleting one cuts their access immediately and independently of the +superusers, so they read node code but cannot change it. Deleting one cuts their access immediately and independently of the portal, so it works even if the portal cannot be reached. See [Accounts and the portal](portal.md). diff --git a/docs/reference/node-types.md b/docs/reference/node-types.md index e9447c1..06afaa8 100644 --- a/docs/reference/node-types.md +++ b/docs/reference/node-types.md @@ -244,6 +244,7 @@ The cheapest fix for a sensor that reports every second and changes every hour. ### Command **`exec`** — run a command in the engine's container and read its output. +Adding one or changing its settings is a superuser's, since the command is code. | Setting | Default | Notes | |---|---|---| diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 325b5a4..98a1435 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -2573,6 +2573,11 @@ export const NodeTypeInfoSchema = { title: 'Has Source', default: false }, + runs_code: { + type: 'boolean', + title: 'Runs Code', + default: false + }, free_params: { type: 'boolean', title: 'Free Params', diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 6e1ba58..9428e45 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -1478,6 +1478,8 @@ export class ModulesService { * Apply Modules * 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. * diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 96faa99..f302818 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -888,6 +888,7 @@ export type NodeTypeInfo = { [key: string]: unknown; }; has_source?: boolean; + runs_code?: boolean; free_params?: boolean; plugin?: (string | null); }; diff --git a/frontend/src/components/Flow/CommandPalette.tsx b/frontend/src/components/Flow/CommandPalette.tsx index a29ed16..8664997 100644 --- a/frontend/src/components/Flow/CommandPalette.tsx +++ b/frontend/src/components/Flow/CommandPalette.tsx @@ -30,13 +30,14 @@ export function CommandPalette({ nodeTypes: NodeTypeInfo[] flows: FlowSummary[] onAddNode: (type: string) => void - onAddSharedNode: (libName: string) => void + /** Absent for an account that may not add code, which a shared node is. */ + onAddSharedNode?: (libName: string) => void onRun: () => void }) { const navigate = useNavigate() const { data: libraryData } = useQuery({ ...libraryQueryOptions(), - enabled: open, + enabled: open && Boolean(onAddSharedNode), }) // Picking an item re-renders the canvas, which can interrupt the dialog's @@ -44,7 +45,7 @@ export function CommandPalette({ // dialog outright is deterministic; the palette does not need to fade out. if (!open) return null - const library = libraryData ?? [] + const library = onAddSharedNode ? (libraryData ?? []) : [] const close = (action: () => void) => { onOpenChange(false) @@ -85,7 +86,7 @@ export function CommandPalette({ close(() => onAddSharedNode(entry.name))} + onSelect={() => close(() => onAddSharedNode?.(entry.name))} > {entry.name} diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index cc22051..692606e 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -42,6 +42,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog" +import useAuth from "@/hooks/useAuth" import useCustomToast from "@/hooks/useCustomToast" import { useIsMobile } from "@/hooks/useMobile" import { scaleIn } from "@/lib/motion" @@ -367,6 +368,22 @@ function FlowEditorInner({ [nodeTypeInfo], ) + // Which types run code of their own, a source or a command. Adding one is a + // superuser's, so nobody else is offered one. + const { user } = useAuth() + const mayEditCode = Boolean(user?.is_superuser) + const lockedTypes = useMemo( + () => + new Set( + mayEditCode + ? [] + : (nodeTypeInfo ?? []) + .filter((info) => info.runs_code) + .map((info) => info.type), + ), + [nodeTypeInfo, mayEditCode], + ) + // Which types came from an installed connector rather than the engine. They // cannot be in the icon map, so they share one. const pluginTypes = useMemo( @@ -1042,9 +1059,18 @@ function FlowEditorInner({ return } + // A node that runs code is a superuser's to add, so anyone else pastes + // the rest without it. + const nodes = (clipboard.nodes ?? []).filter( + (node) => !lockedTypes.has(node.type ?? "python"), + ) + if (nodes.length < (clipboard.nodes ?? []).length) { + notify("Only a superuser can add nodes that run code.", "error") + } + let pool = definitions const pasted: NodeDef_Input[] = [] - for (const node of clipboard.nodes ?? []) { + for (const node of nodes) { const copy = { ...node, id: uniqueNodeId(pool, node.id) } pool = [...pool, copy] pasted.push(copy) @@ -1063,13 +1089,20 @@ function FlowEditorInner({ commit(pool) setSelectedId(pasted[pasted.length - 1].id) - clipboard.nodes.forEach((node, index) => { + nodes.forEach((node, index) => { const code = clipboard.sources?.[node.id] if (code !== undefined) { sourceMutation.mutate({ nodeId: pasted[index].id, code }) } }) - }, [canvasNodes, commit, definitions, setCanvasNodes, sourceMutation.mutate]) + }, [ + canvasNodes, + commit, + definitions, + lockedTypes, + setCanvasNodes, + sourceMutation.mutate, + ]) useShortcuts( { @@ -1335,10 +1368,14 @@ function FlowEditorInner({ !lockedTypes.has(info.type), + )} flows={flows.data} onAddNode={addNode} - onAddSharedNode={(libName) => addNode("python", libName)} + onAddSharedNode={ + mayEditCode ? (libName) => addNode("python", libName) : undefined + } onRun={startRun} /> diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index 6e5e206..a1eec1c 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -42,6 +42,7 @@ import { SelectValue, } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" +import useAuth from "@/hooks/useAuth" import useCustomToast from "@/hooks/useCustomToast" import { inCodeEditor, useShortcuts } from "@/lib/shortcuts" import { cn } from "@/lib/utils" @@ -1204,6 +1205,9 @@ const PLACEHOLDER: Record = { video: 'fluksio.save_artifact(b"", "clip.mp4", media_type="video/mp4")', } +/** Why a code editor or an exec node's settings are read-only. */ +const CODE_LOCKED = "Only a superuser can change code the engine runs." + const SCAFFOLD_DOC = '"""A new node. Return a dict keyed by your output ports."""' @@ -1293,6 +1297,12 @@ function PanelBody({ ...nodeSourceQueryOptions(flow, node.id), enabled: hasSource, }) + // Code the engine runs is a superuser's to change; the server refuses + // anyone else, so the panel shows it read-only rather than failing a save. + const { user } = useAuth() + const mayEditCode = Boolean(user?.is_superuser) + const settingsAreCode = + !mayEditCode && Boolean(nodeType?.runs_code) && !hasSource const [code, setCode] = useState(null) // The port the X is dropping or its name field renaming, noted just before @@ -1333,7 +1343,14 @@ function PanelBody({ const editNode = (next: NodeDef_Input) => { onChange(next) const current = code ?? source?.code - if (!hasSource || next.source_ref || origin || current === undefined) return + if ( + !hasSource || + !mayEditCode || + next.source_ref || + origin || + current === undefined + ) + return const wanted = scaffoldFor(next) if (current !== wanted && SCAFFOLD_SHAPE.test(current)) editCode(wanted) } @@ -1389,6 +1406,16 @@ function PanelBody({ } }, []) + const paramsForm = ( + editNode({ ...node, params })} + /> + ) + return (
- editNode({ ...node, params })} - /> + {settingsAreCode ? ( + /* An exec node's command is its code, so the whole form is. */ +
+ {paramsForm} +

+ {CODE_LOCKED} +

+
+ ) : ( + paramsForm + )} {nodeType?.free_params ? ( {origin.repo || "a repository"} . Edit it there and sync again.

- ) : null} + ) : mayEditCode ? null : ( +

+ {CODE_LOCKED} +

+ )}
diff --git a/frontend/src/routes/_layout/modules.tsx b/frontend/src/routes/_layout/modules.tsx index 10feb32..4c2e07b 100644 --- a/frontend/src/routes/_layout/modules.tsx +++ b/frontend/src/routes/_layout/modules.tsx @@ -6,6 +6,7 @@ import { useState } from "react" import { type ApiError, ModulesService } from "@/client" import { InfoTip } from "@/components/Flow/SidePanel" import { Button } from "@/components/ui/button" +import useAuth from "@/hooks/useAuth" import useCustomToast from "@/hooks/useCustomToast" import { handleError } from "@/utils" @@ -32,6 +33,9 @@ function Modules() { }) const queryClient = useQueryClient() const { showSuccessToast, showErrorToast } = useCustomToast() + // A package runs its own code on install, so installing is a superuser's. + const { user } = useAuth() + const mayInstall = Boolean(user?.is_superuser) // Null until the field is touched, so what is stored shows through until // someone actually starts editing it. const [draft, setDraft] = useState(null) @@ -84,7 +88,14 @@ function Modules() {

- {adopted ? null : ( + {adopted ? null : !mayInstall ? ( +

+ Only a superuser can install packages. +

+ ) : (

Requirements