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
@@ -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 == [""]
+30
View File
@@ -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}