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:
@@ -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 == [""]
|
||||
Reference in New Issue
Block a user