diff --git a/bioengine/_version.py b/bioengine/_version.py index 2c13dcad..a64a172f 100644 --- a/bioengine/_version.py +++ b/bioengine/_version.py @@ -13,4 +13,4 @@ Must stay in lock-step with ``pyproject.toml``'s ``version`` field. The ``version-check.yml`` CI workflow enforces the match. """ -__version__ = "0.16.26" +__version__ = "0.16.27" diff --git a/bioengine/worker/__main__.py b/bioengine/worker/__main__.py index 78219edc..127901ff 100644 --- a/bioengine/worker/__main__.py +++ b/bioengine/worker/__main__.py @@ -110,8 +110,14 @@ def create_parser() -> argparse.ArgumentParser: type=str, nargs="+", metavar="EMAIL", - help="List of user emails/IDs with administrative privileges for worker management. " - "If not specified, defaults to the authenticated user from Hypha login.", + help="Space-separated list of user emails/IDs with administrative privileges for " + "worker management. If not specified, defaults to the authenticated user from " + "Hypha login. SECURITY: passing '*' makes every caller that can reach the Hypha " + "server a full admin, including unauthenticated anonymous ones, because the " + "worker service is public. That lets anyone run arbitrary Python on this " + "deployment ('run_code', 'deploy_app', 'upload_app') and destroy it " + "('stop_worker', 'stop_all_apps'). Use named emails unless this deployment is " + "meant to run untrusted code.", ) core_group.add_argument( "--workspace-dir", diff --git a/bioengine/worker/worker.py b/bioengine/worker/worker.py index e9f82f94..29f4b2f8 100644 --- a/bioengine/worker/worker.py +++ b/bioengine/worker/worker.py @@ -528,6 +528,28 @@ def _load_persisted_admin_users(self) -> None: ) self.admin_users[:] = persisted + def _warn_if_wildcard_admin_users(self) -> None: + """Warn that a wildcard admin list exposes code execution to anyone. + + The deployment guide says this too, but nobody reads it at roll time. + """ + if "*" not in self.admin_users: + return + + self.logger.warning( + "SECURITY: '*' is in this worker's admin users and the worker service is " + "registered with public visibility. Every caller that can reach the Hypha " + "server — including unauthenticated, anonymous ones — is therefore a full " + "admin of this worker. They can run arbitrary Python on this deployment " + "via 'run_code', 'deploy_app' or 'upload_app', with whatever filesystem, " + "credentials and network access its Ray cluster is given; in " + "single-machine mode that is this host, as the user running this process. " + "They can also destroy it via 'stop_worker', 'stop_all_apps' or " + "'delete_app'. This is remote code execution open to the internet, not " + "merely open read access. Replace '*' with named admin emails unless this " + "deployment is genuinely meant to run untrusted code." + ) + def _persist_admin_users(self, admin_users: List[str]) -> None: """Write the admin users so the next restart overlays them on the seed. @@ -1335,6 +1357,8 @@ async def start(self, blocking: bool = True) -> str: # Completes initialization of AppsManager and CodeExecutor await self._connect_to_server(STARTUP_CONNECT_BUDGET_S) + self._warn_if_wildcard_admin_users() + # Check for running data server await self._discover_data_server() diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index 712912f5..d7f03691 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -11,6 +11,16 @@ BioEngine supports three deployment modes. The easiest way to generate deploymen --- +## Who can control the worker + +`--admin-users` takes a space-separated list of emails or user IDs and defaults to the account whose token started the worker. Admins can perform every admin operation on the worker, in every deployment mode. + +> **`--admin-users '*'` is remote code execution open to the internet.** The worker's Hypha service is registered with public visibility, and a service's authorization gates invocation, not discovery. Almost every admin operation is gated on the admin list with the wildcard honoured, so with `*` in the list **any caller that can reach the Hypha server — including an unauthenticated, anonymous one — is a full admin of your worker**. They can run arbitrary Python on the deployment through `run_code`, `deploy_app` or `upload_app`, with whatever filesystem, credentials and network access its Ray cluster is given — in single-machine mode that is the host you started the worker on, as the user who started it. They can also destroy it through `stop_worker`, `stop_all_apps` or `delete_app`. The wildcard is not "skip maintaining an admin list"; it is "this deployment runs untrusted code from strangers". Use named emails unless that is genuinely what you want. The worker logs a warning at startup whenever the wildcard is in effect. + +The one exception is editing the admin list itself: a caller who is only covered by `*` cannot call `add_admin_user` or `remove_admin_user`, so a stranger cannot make the grant permanent or lock you out. + +--- + ## Mode 1: Single Machine Runs a local Ray cluster on one machine. Good for workstations, development, and small-scale analysis. @@ -93,7 +103,7 @@ apptainer exec \ | `--workspace` | auto | Hypha workspace name (auto-detected from token) | | `--server-url` | `https://hypha.aicell.io` | Hypha server URL | | `--token` | prompt | Hypha authentication token | -| `--admin-users` | current user | Comma-separated emails or `*` for all | +| `--admin-users` | current user | Space-separated emails, or `*` for all — see [Who can control the worker](#who-can-control-the-worker) | | `--client-id` | auto | Unique service identifier | The workspace directory defaults to `~/.bioengine` and is mounted into the container at `/.bioengine`. diff --git a/pyproject.toml b/pyproject.toml index 4170131e..43b101b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "bioengine" -version = "0.16.26" +version = "0.16.27" description = "BioEngine — CLI and SDK for deploying and calling AI model services on BioEngine workers" requires-python = ">=3.11" authors = [ diff --git a/tests/worker/test_admin_users.py b/tests/worker/test_admin_users.py index 6dcd0531..ac80acc4 100644 --- a/tests/worker/test_admin_users.py +++ b/tests/worker/test_admin_users.py @@ -8,7 +8,9 @@ restart, when the ``--admin-users`` startup flag is replayed verbatim. """ +import asyncio import json +from types import SimpleNamespace import pytest @@ -36,11 +38,16 @@ def _bare_worker(tmp_path, admin_users, **attrs): class _Logger: def __init__(self): self.records = [] + self.warnings = [] def _record(self, message): self.records.append(str(message)) - info = warning = error = debug = _record + def warning(self, message): + self.warnings.append(str(message)) + self._record(message) + + info = error = debug = _record async def test_a_granted_admin_can_immediately_call_admin_methods(tmp_path): @@ -309,3 +316,59 @@ def __getattr__(self, name): assert registered["list_admin_users"] == worker.list_admin_users assert registered["add_admin_user"] == worker.add_admin_user assert registered["remove_admin_user"] == worker.remove_admin_user + + +async def test_a_wildcard_admin_list_warns_that_anyone_can_run_code(tmp_path): + """The wildcard reads as a permissions shortcut; it is open remote execution.""" + worker = _bare_worker(tmp_path, ["admin@example.org", "*"]) + + worker._warn_if_wildcard_admin_users() + + assert len(worker.logger.warnings) == 1 + warning = worker.logger.warnings[0] + assert "run_code" in warning + assert "anonymous" in warning + + +async def test_a_named_admin_list_does_not_warn(tmp_path): + worker = _bare_worker(tmp_path, ["admin@example.org"]) + + worker._warn_if_wildcard_admin_users() + + assert worker.logger.warnings == [] + + +class _Reached(Exception): + """Ends a start() once the point under test has been passed.""" + + +async def test_the_wildcard_warning_is_emitted_during_startup(tmp_path): + """A warning nobody calls is the same as no warning. + + Ending start() at the step after the call site is what pins it to startup: + production runs blocking=True, so a warning placed later in start() would + not reach an operator until the worker shuts down. + """ + + async def _noop(*_args, **_kwargs): + return None + + async def _reached(*_args, **_kwargs): + raise _Reached + + worker = _bare_worker( + tmp_path, + ["admin@example.org", "*"], + heartbeat_file=tmp_path / "worker_heartbeat.json", + _shutdown_event=asyncio.Event(), + ray_cluster=SimpleNamespace(start=_noop), + _connect_to_server=_noop, + _discover_data_server=_reached, + _stop=_noop, + ) + + with pytest.raises(_Reached): + await worker.start(blocking=True) + + assert len(worker.logger.warnings) == 1 + assert "run_code" in worker.logger.warnings[0]