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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 == [""]
|
||||
@@ -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}
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -888,6 +888,7 @@ export type NodeTypeInfo = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
has_source?: boolean;
|
||||
runs_code?: boolean;
|
||||
free_params?: boolean;
|
||||
plugin?: (string | null);
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
<CommandItem
|
||||
key={entry.name}
|
||||
value={`shared ${entry.name}`}
|
||||
onSelect={() => close(() => onAddSharedNode(entry.name))}
|
||||
onSelect={() => close(() => onAddSharedNode?.(entry.name))}
|
||||
>
|
||||
<span className="flex flex-col">
|
||||
<span className="font-mono">{entry.name}</span>
|
||||
|
||||
@@ -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({
|
||||
<CommandPalette
|
||||
open={paletteOpen}
|
||||
onOpenChange={setPaletteOpen}
|
||||
nodeTypes={nodeTypeInfo ?? []}
|
||||
nodeTypes={(nodeTypeInfo ?? []).filter(
|
||||
(info) => !lockedTypes.has(info.type),
|
||||
)}
|
||||
flows={flows.data}
|
||||
onAddNode={addNode}
|
||||
onAddSharedNode={(libName) => addNode("python", libName)}
|
||||
onAddSharedNode={
|
||||
mayEditCode ? (libName) => addNode("python", libName) : undefined
|
||||
}
|
||||
onRun={startRun}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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<DType, string> = {
|
||||
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<string | null>(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 = (
|
||||
<ParamsForm
|
||||
type={node.type}
|
||||
schema={nodeType?.params_schema}
|
||||
params={node.params ?? {}}
|
||||
provides={node.provides ?? []}
|
||||
onChange={(params) => editNode({ ...node, params })}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -1434,13 +1461,20 @@ function PanelBody({
|
||||
portMove.current = [portName(spec), null]
|
||||
}}
|
||||
/>
|
||||
<ParamsForm
|
||||
type={node.type}
|
||||
schema={nodeType?.params_schema}
|
||||
params={node.params ?? {}}
|
||||
provides={node.provides ?? []}
|
||||
onChange={(params) => editNode({ ...node, params })}
|
||||
/>
|
||||
{settingsAreCode ? (
|
||||
/* An exec node's command is its code, so the whole form is. */
|
||||
<fieldset disabled className="grid min-w-0 gap-2">
|
||||
{paramsForm}
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
data-testid="node-settings-locked"
|
||||
>
|
||||
{CODE_LOCKED}
|
||||
</p>
|
||||
</fieldset>
|
||||
) : (
|
||||
paramsForm
|
||||
)}
|
||||
{nodeType?.free_params ? (
|
||||
<FreeParamsForm
|
||||
params={node.params ?? {}}
|
||||
@@ -1524,7 +1558,14 @@ function PanelBody({
|
||||
<span className="font-mono">{origin.repo || "a repository"}</span>
|
||||
. Edit it there and sync again.
|
||||
</p>
|
||||
) : null}
|
||||
) : mayEditCode ? null : (
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
data-testid="node-source-locked"
|
||||
>
|
||||
{CODE_LOCKED}
|
||||
</p>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border">
|
||||
<Suspense
|
||||
fallback={
|
||||
@@ -1534,7 +1575,7 @@ function PanelBody({
|
||||
<NodeEditor
|
||||
value={code ?? source?.code ?? ""}
|
||||
onChange={editCode}
|
||||
readOnly={Boolean(origin)}
|
||||
readOnly={Boolean(origin) || !mayEditCode}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
@@ -84,7 +88,14 @@ function Modules() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{adopted ? null : (
|
||||
{adopted ? null : !mayInstall ? (
|
||||
<p
|
||||
className="text-sm text-muted-foreground"
|
||||
data-testid="modules-locked"
|
||||
>
|
||||
Only a superuser can install packages.
|
||||
</p>
|
||||
) : (
|
||||
<section className="grid gap-3">
|
||||
<h2 className={SECTION}>Requirements</h2>
|
||||
<textarea
|
||||
|
||||
Reference in New Issue
Block a user