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.
139 lines
4.7 KiB
Python
139 lines
4.7 KiB
Python
"""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 == [""]
|