diff --git a/doc/source/dev/ga4gh_wes.md b/doc/source/dev/ga4gh_wes.md index 5556de4281a..bd26d26e698 100644 --- a/doc/source/dev/ga4gh_wes.md +++ b/doc/source/dev/ga4gh_wes.md @@ -129,16 +129,16 @@ Trimmed to the WES-relevant fields (the real response also carries A WES `RunRequest` is `multipart/form-data`. The fields Galaxy honors: -| Field | Required | Notes | -| --------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------- | -| `workflow_type` | yes | `gx_workflow_format2` or `gx_workflow_ga`. Must match the auto-detected type or you get a 400. | -| `workflow_type_version` | yes | Free-form string, e.g. `"1.0.0"`. | -| `workflow_url` | one of url/attachment | A URL Galaxy can fetch (`http(s)`, `s3`, `gs`, `file`, `base64://`) **or** a `gxworkflow://` reference (see below). | -| `workflow_attachment` | one of url/attachment | The workflow file uploaded inline. | -| `workflow_params` | no | JSON object of workflow inputs (see below). | -| `workflow_engine_parameters` | no | JSON object of Galaxy-specific run options (see below). | -| `tags` | no | Accepted but currently not persisted onto the invocation. | -| `workflow_engine` / `workflow_engine_version` | no | Accepted; informational. | +| Field | Required | Notes | +| --------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `workflow_type` | yes | `gx_workflow_format2` or `gx_workflow_ga`. Must match the auto-detected type or you get a 400. | +| `workflow_type_version` | yes | Free-form string, e.g. `"1.0.0"`. | +| `workflow_url` | one of url/attachment | A URL Galaxy can fetch (`http(s)://`, `s3://`, `gs://`, `file://`, `base64://`) **or** a `gxworkflow://` reference (see below). | +| `workflow_attachment` | one of url/attachment | The workflow file uploaded inline. | +| `workflow_params` | no | JSON object of workflow inputs (see below). | +| `workflow_engine_parameters` | no | JSON object of Galaxy-specific run options (see below). | +| `tags` | no | Accepted but currently not persisted onto the invocation. | +| `workflow_engine` / `workflow_engine_version` | no | Accepted; informational. | ### `workflow_params` — wiring up inputs @@ -180,7 +180,9 @@ gxworkflow:// # the StoredWorkflow (latest versi gxworkflow://?instance=true # a specific Workflow instance ``` -The caller must own the workflow (or be an admin). With `gxworkflow://`, Galaxy skips +The workflow just has to be accessible to the caller — the same rule a normal invocation +uses: owned by them, shared with them, published/importable, or the caller is an admin. +Otherwise you get a 403. With `gxworkflow://`, Galaxy skips import and invokes the stored workflow directly. `workflow_type` is still required by the form but is **not** validated against the stored workflow in this case — the "must match the auto-detected type or 400" check only applies to inline @@ -400,8 +402,8 @@ Access to resources you do not own is **not** reported uniformly — watch for t - Reading a **run** you don't own → `403` (`AuthenticationRequired`). - Submitting against a **history** you don't own → `404` (`ObjectNotFound`). -- A `gxworkflow://` reference to a **workflow** you don't own → `403` - (`ItemAccessibilityException`). +- A `gxworkflow://` reference to a **workflow** you cannot access (not owned, not shared + with you, not published) → `403` (`ItemAccessibilityException`). So a `404` on submission can mean "your history id is wrong" _or_ "that history belongs to someone else"; don't assume `404` always means the object is absent. diff --git a/lib/galaxy/webapps/galaxy/services/wes.py b/lib/galaxy/webapps/galaxy/services/wes.py index 8ed1a839275..3d3ac701847 100644 --- a/lib/galaxy/webapps/galaxy/services/wes.py +++ b/lib/galaxy/webapps/galaxy/services/wes.py @@ -483,22 +483,14 @@ class WesService(ServiceBase): workflow_uri = workflow_dict["workflow_uri"] encoded_workflow_id, instance = _parse_gxworkflow_uri(workflow_uri) - # Load the workflow from the database + # Load the workflow from the database, applying the same accessibility + # rules as a normal invocation (owned, shared, published, or admin). # by_stored_id=not instance means: # - False (instance=False) -> load StoredWorkflow (by_stored_id=True) # - True (instance=True) -> load Workflow (by_stored_id=False) - try: - stored_workflow = self._workflows_service._workflows_manager.get_stored_workflow( - trans, encoded_workflow_id, by_stored_id=not instance - ) - except Exception as e: - raise exceptions.ObjectNotFound( - f"Workflow '{encoded_workflow_id}' not found or not accessible: {str(e)}" - ) - - # Validate user has access to this workflow - if stored_workflow.user_id != trans.user.id and not trans.user_is_admin: - raise exceptions.ItemAccessibilityException("You do not have access to this workflow") + stored_workflow = self._workflows_service._workflows_manager.get_stored_accessible_workflow( + trans, encoded_workflow_id, by_stored_id=not instance + ) # Use the existing workflow directly - no need to create a new one # Skip to step 5 (engine parameters and history) diff --git a/lib/galaxy_test/api/test_wes.py b/lib/galaxy_test/api/test_wes.py index 1b601595547..4eba1a99748 100644 --- a/lib/galaxy_test/api/test_wes.py +++ b/lib/galaxy_test/api/test_wes.py @@ -546,6 +546,44 @@ steps: # Validate response assert run_id is not None + def test_wes_submit_run_with_gxworkflow_uri_published_workflow(self): + """A published workflow owned by another user can be run via gxworkflow://.""" + with self._different_user(): + workflow_id = self._upload_yaml_workflow(WORKFLOW_SIMPLE) + self.workflow_populator.make_public(workflow_id) + + with self.dataset_populator.test_history() as history_id: + dataset_id = self._get_test_dataset_id(history_id) + + data = { + "workflow_type": "gx_workflow_ga", + "workflow_type_version": "v1", + "workflow_params": json.dumps({"input1": dataset_id}), + "workflow_url": f"gxworkflow://{workflow_id}", + } + + response = self._wes_post("ga4gh/wes/v1/runs", data=data) + self._assert_status_code_is(response, 200) + assert response.json()["run_id"] is not None + + def test_wes_submit_run_with_gxworkflow_uri_inaccessible_workflow(self): + """An unshared workflow owned by another user cannot be run via gxworkflow://.""" + with self._different_user(): + workflow_id = self._upload_yaml_workflow(WORKFLOW_SIMPLE) + + with self.dataset_populator.test_history() as history_id: + dataset_id = self._get_test_dataset_id(history_id) + + data = { + "workflow_type": "gx_workflow_ga", + "workflow_type_version": "v1", + "workflow_params": json.dumps({"input1": dataset_id}), + "workflow_url": f"gxworkflow://{workflow_id}", + } + + response = self._wes_post("ga4gh/wes/v1/runs", data=data) + self._assert_status_code_is(response, 403) + def test_wes_job_stdout_endpoint(self): """Test /api/jobs/{job_id}/stdout endpoint returns job stdout.""" with self.dataset_populator.test_history() as history_id: