diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index a8ddacee9b22..87e99248cd64 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -7175,6 +7175,12 @@ export interface components { CreateWorkflowLandingRequestPayload: { /** Client Secret */ client_secret?: string | null; + /** + * Public + * @description If workflow landing request is public anyone with the uuid can use the landing request. If not public the request must be claimed before use and additional verification might occur. + * @default false + */ + public: boolean; /** Request State */ request_state?: Record | null; /** Workflow Id */ @@ -7183,7 +7189,7 @@ export interface components { * Workflow Target Type * @enum {string} */ - workflow_target_type: "stored_workflow" | "workflow"; + workflow_target_type: "stored_workflow" | "workflow" | "trs_url"; }; /** CreatedEntryResponse */ CreatedEntryResponse: { @@ -18235,7 +18241,7 @@ export interface components { * Workflow Target Type * @enum {string} */ - workflow_target_type: "stored_workflow" | "workflow"; + workflow_target_type: "stored_workflow" | "workflow" | "trs_url"; }; /** WriteInvocationStoreToPayload */ WriteInvocationStoreToPayload: { diff --git a/client/src/components/Landing/WorkflowLanding.vue b/client/src/components/Landing/WorkflowLanding.vue index b5f7e2126809..4e8cd948ba21 100644 --- a/client/src/components/Landing/WorkflowLanding.vue +++ b/client/src/components/Landing/WorkflowLanding.vue @@ -1,9 +1,11 @@ diff --git a/client/src/components/Workflow/Run/WorkflowRun.vue b/client/src/components/Workflow/Run/WorkflowRun.vue index 5e8d9dea8bb6..1296010ba689 100644 --- a/client/src/components/Workflow/Run/WorkflowRun.vue +++ b/client/src/components/Workflow/Run/WorkflowRun.vue @@ -31,6 +31,7 @@ interface Props { simpleFormTargetHistory?: string; simpleFormUseJobCache?: boolean; requestState?: Record; + instance?: boolean; } const props = withDefaults(defineProps(), { @@ -39,6 +40,7 @@ const props = withDefaults(defineProps(), { simpleFormTargetHistory: "current", simpleFormUseJobCache: false, requestState: undefined, + instance: false, }); const loading = ref(true); @@ -80,7 +82,7 @@ function handleSubmissionError(error: string) { async function loadRun() { try { - const runData = await getRunData(props.workflowId, props.version || undefined); + const runData = await getRunData(props.workflowId, props.version || undefined, props.instance); const incomingModel = new WorkflowRunModel(runData); simpleForm.value = props.preferSimpleForm; diff --git a/client/src/components/Workflow/Run/services.js b/client/src/components/Workflow/Run/services.js index c6c841a09a79..107ce4a4b8b2 100644 --- a/client/src/components/Workflow/Run/services.js +++ b/client/src/components/Workflow/Run/services.js @@ -12,8 +12,8 @@ import { rethrowSimple } from "utils/simple-error"; * @param {String} workflowId - (Stored?) Workflow ID to fetch data for. * @param {String} version - Version of the workflow to fetch. */ -export async function getRunData(workflowId, version = null) { - let url = `${getAppRoot()}api/workflows/${workflowId}/download?style=run`; +export async function getRunData(workflowId, version = null, instance = false) { + let url = `${getAppRoot()}api/workflows/${workflowId}/download?style=run&instance=${instance}`; if (version) { url += `&version=${version}`; } diff --git a/client/src/entry/analysis/router.js b/client/src/entry/analysis/router.js index 837e70a574bc..011bb9ffd082 100644 --- a/client/src/entry/analysis/router.js +++ b/client/src/entry/analysis/router.js @@ -512,7 +512,11 @@ export function getRouter(Galaxy) { { path: "workflow_landings/:uuid", component: WorkflowLanding, - props: true, + props: (route) => ({ + uuid: route.params.uuid, + public: route.query.public.toLowerCase() === "true", + secret: route.query.client_secret, + }), }, { path: "user", diff --git a/lib/galaxy/exceptions/__init__.py b/lib/galaxy/exceptions/__init__.py index 749b80a57433..0973440f5943 100644 --- a/lib/galaxy/exceptions/__init__.py +++ b/lib/galaxy/exceptions/__init__.py @@ -241,6 +241,10 @@ class Conflict(MessageException): err_code = error_codes_by_name["CONFLICT"] +class ItemMustBeClaimed(Conflict): + err_code = error_codes_by_name["MUST_CLAIM"] + + class DeprecatedMethod(MessageException): """ Method (or a particular form/arg signature) has been removed and won't be available later diff --git a/lib/galaxy/exceptions/error_codes.json b/lib/galaxy/exceptions/error_codes.json index cfaca436a65c..6c349f2218d9 100644 --- a/lib/galaxy/exceptions/error_codes.json +++ b/lib/galaxy/exceptions/error_codes.json @@ -164,6 +164,11 @@ "code": 409001, "message": "Database conflict prevented fulfilling the request." }, + { + "name": "MUST_CLAIM", + "code": 409010, + "message": "Private request must be claimed before use" + }, { "name": "DEPRECATED_API_CALL", "code": 410001, diff --git a/lib/galaxy/managers/landing.py b/lib/galaxy/managers/landing.py index 2138c0c81ca2..9facbac703c6 100644 --- a/lib/galaxy/managers/landing.py +++ b/lib/galaxy/managers/landing.py @@ -4,16 +4,21 @@ ) from uuid import uuid4 +import yaml from pydantic import UUID4 from sqlalchemy import select from galaxy.exceptions import ( - InconsistentDatabase, InsufficientPermissionsException, ItemAlreadyClaimedException, + ItemMustBeClaimed, ObjectNotFound, RequestParameterMissingException, ) +from galaxy.managers.workflows import ( + WorkflowContentsManager, + WorkflowCreateOptions, +) from galaxy.model import ( ToolLandingRequest as ToolLandingRequestModel, WorkflowLandingRequest as WorkflowLandingRequestModel, @@ -29,6 +34,7 @@ WorkflowLandingRequest, ) from galaxy.security.idencoding import IdEncodingHelper +from galaxy.structured_app import StructuredApp from galaxy.util import safe_str_cmp from .context import ProvidesUserContext @@ -37,17 +43,26 @@ class LandingRequestManager: - def __init__(self, sa_session: galaxy_scoped_session, security: IdEncodingHelper): + def __init__( + self, + sa_session: galaxy_scoped_session, + security: IdEncodingHelper, + workflow_contents_manager: WorkflowContentsManager, + ): self.sa_session = sa_session self.security = security + self.workflow_contents_manager = workflow_contents_manager - def create_tool_landing_request(self, payload: CreateToolLandingRequestPayload) -> ToolLandingRequest: + def create_tool_landing_request(self, payload: CreateToolLandingRequestPayload, user_id=None) -> ToolLandingRequest: model = ToolLandingRequestModel() model.tool_id = payload.tool_id model.tool_version = payload.tool_version model.request_state = payload.request_state model.uuid = uuid4() model.client_secret = payload.client_secret + model.public = payload.public + if user_id: + model.user_id = user_id self._save(model) return self._tool_response(model) @@ -55,11 +70,16 @@ def create_workflow_landing_request(self, payload: CreateWorkflowLandingRequestP model = WorkflowLandingRequestModel() if payload.workflow_target_type == "stored_workflow": model.stored_workflow_id = self.security.decode_id(payload.workflow_id) - else: + elif payload.workflow_target_type == "workflow": model.workflow_id = self.security.decode_id(payload.workflow_id) - model.request_state = payload.request_state + elif payload.workflow_target_type == "trs_url": + model.workflow_source_type = "trs_url" + # validate this ? + model.workflow_source = payload.workflow_id model.uuid = uuid4() model.client_secret = payload.client_secret + model.request_state = payload.request_state + model.public = payload.public self._save(model) return self._workflow_response(model) @@ -77,16 +97,39 @@ def claim_workflow_landing_request( ) -> WorkflowLandingRequest: request = self._get_workflow_landing_request(uuid) self._check_can_claim(trans, request, claim) + self._ensure_workflow(trans, request) request.user_id = trans.user.id self._save(request) return self._workflow_response(request) + def _ensure_workflow(self, trans: ProvidesUserContext, request: WorkflowLandingRequestModel): + if request.workflow_source_type == "trs_url" and isinstance(trans.app, StructuredApp): + # trans is always structured app except for unit test + assert request.workflow_source + trs_id, trs_version = request.workflow_source.rsplit("/", 1) + _, trs_id, trs_version = trans.app.trs_proxy.get_trs_id_and_version_from_trs_url(request.workflow_source) + workflow = self.workflow_contents_manager.get_workflow_by_trs_id_and_version( + self.sa_session, trs_id=trs_id, trs_version=trs_version, user_id=trans.user and trans.user.id + ) + if not workflow: + data = trans.app.trs_proxy.get_version_from_trs_url(request.workflow_source) + as_dict = yaml.safe_load(data) + raw_workflow_description = self.workflow_contents_manager.normalize_workflow_format(trans, as_dict) + created_workflow = self.workflow_contents_manager.build_workflow_from_raw_description( + trans, + raw_workflow_description, + WorkflowCreateOptions(), + ) + workflow = created_workflow.workflow + request.workflow_id = workflow.id + def get_tool_landing_request(self, trans: ProvidesUserContext, uuid: UUID4) -> ToolLandingRequest: request = self._get_claimed_tool_landing_request(trans, uuid) return self._tool_response(request) def get_workflow_landing_request(self, trans: ProvidesUserContext, uuid: UUID4) -> WorkflowLandingRequest: request = self._get_claimed_workflow_landing_request(trans, uuid) + self._ensure_workflow(trans, request) return self._workflow_response(request) def _check_can_claim( @@ -139,18 +182,20 @@ def _tool_response(self, model: ToolLandingRequestModel) -> ToolLandingRequest: return response_model def _workflow_response(self, model: WorkflowLandingRequestModel) -> WorkflowLandingRequest: - workflow_id: Optional[int] + + workflow_id: Optional[Union[int, str]] = None if model.stored_workflow_id is not None: workflow_id = model.stored_workflow_id target_type = "stored_workflow" - else: + elif model.workflow_id is not None: workflow_id = model.workflow_id target_type = "workflow" - if workflow_id is None: - raise InconsistentDatabase() + elif model.workflow_source_type == "trs_url": + target_type = model.workflow_source_type + workflow_id = model.workflow_source assert workflow_id response_model = WorkflowLandingRequest( - workflow_id=self.security.encode_id(workflow_id), + workflow_id=self.security.encode_id(workflow_id) if isinstance(workflow_id, int) else workflow_id, workflow_target_type=target_type, request_state=model.request_state, uuid=model.uuid, @@ -159,7 +204,9 @@ def _workflow_response(self, model: WorkflowLandingRequestModel) -> WorkflowLand return response_model def _check_ownership(self, trans: ProvidesUserContext, model: LandingRequestModel): - if model.user_id != trans.user.id: + if not model.public and self._state(model) == LandingRequestState.UNCLAIMED: + raise ItemMustBeClaimed + if model.user_id and model.user_id != trans.user.id: raise InsufficientPermissionsException() def _state(self, model: LandingRequestModel) -> LandingRequestState: diff --git a/lib/galaxy/managers/workflows.py b/lib/galaxy/managers/workflows.py index de6a3a472f9d..d69e8afcab70 100644 --- a/lib/galaxy/managers/workflows.py +++ b/lib/galaxy/managers/workflows.py @@ -30,6 +30,9 @@ WrapSerializer, ) from sqlalchemy import ( + and_, + Cast, + ColumnElement, desc, false, func, @@ -37,6 +40,7 @@ select, true, ) +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import ( aliased, joinedload, @@ -85,6 +89,7 @@ text_column_filter, ) from galaxy.model.item_attrs import UsesAnnotations +from galaxy.model.scoped_session import galaxy_scoped_session from galaxy.schema.invocation import InvocationCancellationUserRequest from galaxy.schema.schema import WorkflowIndexQueryPayload from galaxy.structured_app import MinimalManagerApp @@ -2011,6 +2016,39 @@ def get_all_tools(self, workflow): tools.extend(self.get_all_tools(step.subworkflow)) return tools + def get_workflow_by_trs_id_and_version( + self, sa_session: galaxy_scoped_session, trs_id: str, trs_version: str, user_id: Optional[int] = None + ) -> Optional[model.Workflow]: + def to_json(column, keys: List[str]): + assert sa_session.bind + if sa_session.bind.dialect.name == "postgresql": + cast: Union[ColumnElement[Any], Cast[Any]] = func.cast(func.convert_from(column, "UTF8"), JSONB) + for key in keys: + cast = cast.__getitem__(key) + return cast.astext + else: + for key in keys: + column = column.__getitem__(key) + return column + + stmnt = ( + select(model.Workflow) + .join(model.StoredWorkflow, model.Workflow.stored_workflow_id == model.StoredWorkflow.id) + .filter( + and_( + to_json(model.Workflow.source_metadata, ["trs_tool_id"]) == trs_id, + to_json(model.Workflow.source_metadata, ["trs_version_id"]) == trs_version, + ) + ) + ) + if user_id: + stmnt = stmnt.filter( + model.StoredWorkflow.user_id == user_id, model.StoredWorkflow.latest_workflow_id == model.Workflow.id + ) + else: + stmnt = stmnt.filter(model.StoredWorkflow.importable == true()) + return sa_session.execute(stmnt).scalar() + class RefactorRequest(RefactorActions): style: str = "export" diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index 64e3c143e736..f6ca59da5711 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -11279,6 +11279,7 @@ class ToolLandingRequest(Base): tool_version: Mapped[Optional[str]] = mapped_column(String(255), default=None) request_state: Mapped[Optional[Dict]] = mapped_column(JSONType) client_secret: Mapped[Optional[str]] = mapped_column(String(255), default=None) + public: Mapped[bool] = mapped_column(Boolean) user: Mapped[Optional["User"]] = relationship() @@ -11297,6 +11298,9 @@ class WorkflowLandingRequest(Base): uuid: Mapped[Union[UUID, str]] = mapped_column(UUIDType(), index=True) request_state: Mapped[Optional[Dict]] = mapped_column(JSONType) client_secret: Mapped[Optional[str]] = mapped_column(String(255), default=None) + workflow_source: Mapped[Optional[str]] = mapped_column(String(255), default=None) + workflow_source_type: Mapped[Optional[str]] = mapped_column(String(255), default=None) + public: Mapped[bool] = mapped_column(Boolean) user: Mapped[Optional["User"]] = relationship() stored_workflow: Mapped[Optional["StoredWorkflow"]] = relationship() diff --git a/lib/galaxy/model/migrations/alembic/versions_gxy/a99a5b52ccb8_add_workflow_source_and_workflow_source_.py b/lib/galaxy/model/migrations/alembic/versions_gxy/a99a5b52ccb8_add_workflow_source_and_workflow_source_.py new file mode 100644 index 000000000000..275f11df8f8c --- /dev/null +++ b/lib/galaxy/model/migrations/alembic/versions_gxy/a99a5b52ccb8_add_workflow_source_and_workflow_source_.py @@ -0,0 +1,47 @@ +"""Add workflow_source and workflow_source_type and public column + +Revision ID: a99a5b52ccb8 +Revises: 7ffd33d5d144 +Create Date: 2024-10-10 14:23:29.485113 + +""" + +import sqlalchemy as sa + +from galaxy.model.migrations.util import ( + add_column, + column_exists, + drop_column, + transaction, +) + +# revision identifiers, used by Alembic. +revision = "a99a5b52ccb8" +down_revision = "7ffd33d5d144" +branch_labels = None +depends_on = None + +workflow_table_name = "workflow_landing_request" +tool_table_name = "tool_landing_request" + + +def drop_if_exists(table_name: str, column_name: str): + if column_exists(table_name, column_name, True): + drop_column(table_name, column_name) + + +def upgrade(): + with transaction(): + add_column(workflow_table_name, sa.Column("workflow_source", sa.String(255), nullable=True)) + add_column(workflow_table_name, sa.Column("workflow_source_type", sa.String(255), nullable=True)) + add_column(workflow_table_name, sa.Column("public", sa.Boolean, nullable=False, server_default=sa.false())) + add_column(tool_table_name, sa.Column("public", sa.Boolean, nullable=False, server_default=sa.false())) + + +def downgrade(): + with transaction(): + drop_column(workflow_table_name, "workflow_source") + drop_column(workflow_table_name, "workflow_source_type") + # For test.galaxyproject.org which was deployed from PR branch that didn't contain public column + drop_if_exists(workflow_table_name, "public") + drop_if_exists(tool_table_name, "public") diff --git a/lib/galaxy/schema/schema.py b/lib/galaxy/schema/schema.py index f3904dc3d35e..0d9646316e59 100644 --- a/lib/galaxy/schema/schema.py +++ b/lib/galaxy/schema/schema.py @@ -3867,13 +3867,18 @@ class CreateToolLandingRequestPayload(Model): tool_version: Optional[str] = None request_state: Optional[Dict[str, Any]] = None client_secret: Optional[str] = None + public: bool = False class CreateWorkflowLandingRequestPayload(Model): workflow_id: str - workflow_target_type: Literal["stored_workflow", "workflow"] + workflow_target_type: Literal["stored_workflow", "workflow", "trs_url"] request_state: Optional[Dict[str, Any]] = None client_secret: Optional[str] = None + public: bool = Field( + False, + description="If workflow landing request is public anyone with the uuid can use the landing request. If not public the request must be claimed before use and additional verification might occur.", + ) class ClaimLandingPayload(Model): @@ -3891,7 +3896,7 @@ class ToolLandingRequest(Model): class WorkflowLandingRequest(Model): uuid: UuidField workflow_id: str - workflow_target_type: Literal["stored_workflow", "workflow"] + workflow_target_type: Literal["stored_workflow", "workflow", "trs_url"] request_state: Dict[str, Any] state: LandingRequestState diff --git a/lib/galaxy/selenium/navigates_galaxy.py b/lib/galaxy/selenium/navigates_galaxy.py index 49fcea2e1260..118248fe0d66 100644 --- a/lib/galaxy/selenium/navigates_galaxy.py +++ b/lib/galaxy/selenium/navigates_galaxy.py @@ -299,6 +299,13 @@ def home(self) -> None: except SeleniumTimeoutException as e: raise ClientBuildException(e) + def go_to_workflow_landing(self, uuid: str, public: Literal["false", "true"], client_secret: Optional[str]): + path = f"workflow_landings/{uuid}?public={public}" + if client_secret: + path = f"{path}&client_secret={client_secret}" + self.driver.get(self.build_url(path)) + self.components.workflow_run.run_workflow.wait_for_visible() + def go_to_trs_search(self) -> None: self.driver.get(self.build_url("workflows/trs_search")) self.components.masthead._.wait_for_visible() diff --git a/lib/galaxy/tool_util/client/landing.py b/lib/galaxy/tool_util/client/landing.py index 425559a87ffa..e089ae268689 100644 --- a/lib/galaxy/tool_util/client/landing.py +++ b/lib/galaxy/tool_util/client/landing.py @@ -65,7 +65,10 @@ def generate_claim_url(request: Request) -> Response: landing_request_url, json=template, ) - raw_response.raise_for_status() + try: + raw_response.raise_for_status() + except Exception: + raise Exception("Request failed: %s", raw_response.text) response = raw_response.json() url = f"{galaxy_url}/{template_type}_landings/{response['uuid']}" if client_secret: diff --git a/lib/galaxy/webapps/galaxy/api/workflows.py b/lib/galaxy/webapps/galaxy/api/workflows.py index 719029e818d6..6f94550e2134 100644 --- a/lib/galaxy/webapps/galaxy/api/workflows.py +++ b/lib/galaxy/webapps/galaxy/api/workflows.py @@ -105,6 +105,7 @@ BaseGalaxyAPIController, depends, DependsOnTrans, + DependsOnUser, IndexQueryTag, LandingUuidPathParam, Router, @@ -1171,11 +1172,7 @@ def create_landing( trans: ProvidesUserContext = DependsOnTrans, workflow_landing_request: CreateWorkflowLandingRequestPayload = Body(...), ) -> WorkflowLandingRequest: - try: - return self.landing_manager.create_workflow_landing_request(workflow_landing_request) - except Exception: - log.exception("Problem...") - raise + return self.landing_manager.create_workflow_landing_request(workflow_landing_request) @router.post("/api/workflow_landings/{uuid}/claim") def claim_landing( @@ -1183,18 +1180,16 @@ def claim_landing( trans: ProvidesUserContext = DependsOnTrans, uuid: UUID4 = LandingUuidPathParam, payload: Optional[ClaimLandingPayload] = Body(...), + user: model.User = DependsOnUser, ) -> WorkflowLandingRequest: - try: - return self.landing_manager.claim_workflow_landing_request(trans, uuid, payload) - except Exception: - log.exception("claiim problem...") - raise + return self.landing_manager.claim_workflow_landing_request(trans, uuid, payload) @router.get("/api/workflow_landings/{uuid}") def get_landing( self, trans: ProvidesUserContext = DependsOnTrans, uuid: UUID4 = LandingUuidPathParam, + user: model.User = DependsOnUser, ) -> WorkflowLandingRequest: return self.landing_manager.get_workflow_landing_request(trans, uuid) diff --git a/lib/galaxy/workflow/trs_proxy.py b/lib/galaxy/workflow/trs_proxy.py index 2e6d1acaa310..550238ffa229 100644 --- a/lib/galaxy/workflow/trs_proxy.py +++ b/lib/galaxy/workflow/trs_proxy.py @@ -6,6 +6,7 @@ import yaml +from galaxy.config import Configuration from galaxy.exceptions import ( MessageException, RequestParameterInvalidException, @@ -59,8 +60,8 @@ def parse_search_kwds(search_query): class TrsProxy: - def __init__(self, config=None): - config_file = getattr(config, "trs_servers_config_file", None) + def __init__(self, config: Configuration): + config_file = config.trs_servers_config_file if config_file and os.path.exists(config_file): with open(config_file) as f: server_list = yaml.safe_load(f) @@ -68,6 +69,7 @@ def __init__(self, config=None): server_list = DEFAULT_TRS_SERVERS self._server_list = server_list if server_list else [] self._server_dict = {t["id"]: t for t in self._server_list} + self.fetch_url_allowlist_ips = config.fetch_url_allowlist_ips def get_servers(self): return self._server_list @@ -79,6 +81,16 @@ def get_server(self, trs_server): def server_from_url(self, trs_url): return TrsServer(trs_url) + def get_trs_id_and_version_from_trs_url(self, trs_url): + parts = self.match_url(trs_url, self.fetch_url_allowlist_ips) + if parts: + return self.server_from_url(parts["trs_base_url"]), parts["tool_id"], parts["version_id"] + raise RequestParameterInvalidException(f"Invalid TRS URL {trs_url}.") + + def get_version_from_trs_url(self, trs_url): + server, trs_tool_id, trs_version_id = self.get_trs_id_and_version_from_trs_url(trs_url=trs_url) + return server.get_version_descriptor(trs_tool_id, trs_version_id) + def match_url(self, url, ip_allowlist: List[IpAllowedListEntryT]): if url.lstrip().startswith("file://"): # requests doesn't know what to do with file:// anyway, but just in case we swap diff --git a/lib/galaxy_test/api/test_landing.py b/lib/galaxy_test/api/test_landing.py index 84057f92097e..53182fadae2a 100644 --- a/lib/galaxy_test/api/test_landing.py +++ b/lib/galaxy_test/api/test_landing.py @@ -4,7 +4,10 @@ Dict, ) -from galaxy.schema.schema import CreateWorkflowLandingRequestPayload +from galaxy.schema.schema import ( + CreateWorkflowLandingRequestPayload, + WorkflowLandingRequest, +) from galaxy_test.base.populators import ( DatasetPopulator, skip_without_tool, @@ -23,22 +26,85 @@ def setUp(self): self.workflow_populator = WorkflowPopulator(self.galaxy_interactor) @skip_without_tool("cat1") - def test_workflow_landing(self): - workflow_id = self.workflow_populator.simple_workflow("test_landing") - workflow_target_type = "stored_workflow" - request_state = _workflow_request_state() - request = CreateWorkflowLandingRequestPayload( - workflow_id=workflow_id, - workflow_target_type=workflow_target_type, - request_state=request_state, - ) + def test_create_public_workflow_landing_authenticated_user(self): + request = _get_simple_landing_payload(self.workflow_populator, public=True) + response = self.dataset_populator.create_workflow_landing(request) + assert response.workflow_id == request.workflow_id + assert response.workflow_target_type == request.workflow_target_type + + with self._different_user(): + # Can use without claim + _can_use_request(self.dataset_populator, response) + + with self._different_user(anon=True): + # Cannot claim since can't run workflows + _cannot_use_request(self.dataset_populator, response) + + # Should claim of public landing request be denied ? + # Yes, so other user cannot own workflow in between use and invocation submission + # No, since there's no way to delete a landing request. If you accidentally make something + # with a private reference public you can't delete the landing page request. + # TODO: allow deleting landing request, deny claiming public requests ? + + with self._different_user(): + _can_claim_request(self.dataset_populator, response) + _can_use_request(self.dataset_populator, response) + + # Cannot use if other user claimed + _cannot_claim_request(self.dataset_populator, response) + + @skip_without_tool("cat1") + def test_create_public_workflow_landing_anonymous_user(self): + # Anonymous user can create public landing request + request = _get_simple_landing_payload(self.workflow_populator, public=True) + with self._different_user(anon=True): + response = self.dataset_populator.create_workflow_landing(request) + + with self._different_user(): + # Can use without claim + _can_use_request(self.dataset_populator, response) + + with self._different_user(anon=True): + # Cannot claim since can't run workflows + _cannot_use_request(self.dataset_populator, response) + + with self._different_user(): + _can_claim_request(self.dataset_populator, response) + _can_use_request(self.dataset_populator, response) + + # Cannot use if other user claimed + _cannot_claim_request(self.dataset_populator, response) + + @skip_without_tool("cat1") + def test_create_private_workflow_landing_authenticated_user(self): + request = _get_simple_landing_payload(self.workflow_populator, public=False) response = self.dataset_populator.create_workflow_landing(request) - assert response.workflow_id == workflow_id - assert response.workflow_target_type == workflow_target_type + with self._different_user(): + # Must be claimed first + _cannot_use_request(self.dataset_populator, response, expect_status_code=409) + _can_claim_request(self.dataset_populator, response) + # Can be used after claim by same user + _can_use_request(self.dataset_populator, response) + + # other user claimed, so we can't use + _cannot_claim_request(self.dataset_populator, response) + _cannot_use_request(self.dataset_populator, response) + + @skip_without_tool("cat1") + def test_create_private_workflow_landing_anonymous_user(self): + request = _get_simple_landing_payload(self.workflow_populator, public=False) + with self._different_user(anon=True): + response = self.dataset_populator.create_workflow_landing(request) + with self._different_user(): + # Must be claimed first + _cannot_use_request(self.dataset_populator, response, expect_status_code=409) + _can_claim_request(self.dataset_populator, response) + # Can be used after claim by same user + _can_use_request(self.dataset_populator, response) - response = self.dataset_populator.claim_workflow_landing(response.uuid) - assert response.workflow_id == workflow_id - assert response.workflow_target_type == workflow_target_type + # other user claimed, so we can't use + _cannot_claim_request(self.dataset_populator, response) + _cannot_use_request(self.dataset_populator, response) def _workflow_request_state() -> Dict[str, Any]: @@ -50,3 +116,51 @@ def _workflow_request_state() -> Dict[str, Any]: "WorkflowInput2": {"src": "url", "url": f"base64://{input_b64_2}", "ext": "txt", "deferred": deferred}, } return inputs + + +def _get_simple_landing_payload(workflow_populator: WorkflowPopulator, public: bool = False): + workflow_id = workflow_populator.simple_workflow("test_landing") + if public: + workflow_populator.make_public(workflow_id) + workflow_target_type = "stored_workflow" + request_state = _workflow_request_state() + return CreateWorkflowLandingRequestPayload( + workflow_id=workflow_id, + workflow_target_type=workflow_target_type, + request_state=request_state, + public=public, + ) + + +def _can_claim_request(dataset_populator: DatasetPopulator, request: WorkflowLandingRequest): + response = dataset_populator.claim_workflow_landing(request.uuid) + assert response.workflow_id == request.workflow_id + assert response.workflow_target_type == request.workflow_target_type + + +def _cannot_claim_request(dataset_populator: DatasetPopulator, request: WorkflowLandingRequest): + exception_encountered = False + try: + _can_claim_request(dataset_populator, request) + except Exception as e: + assert "Request status code (403)" in str(e) + exception_encountered = True + assert exception_encountered, "Expected claim to fail" + + +def _can_use_request(dataset_populator: DatasetPopulator, request: WorkflowLandingRequest): + response = dataset_populator.use_workflow_landing(request.uuid) + assert response.workflow_id == request.workflow_id + assert response.workflow_target_type == request.workflow_target_type + + +def _cannot_use_request( + dataset_populator: DatasetPopulator, request: WorkflowLandingRequest, expect_status_code: int = 403 +): + exception_encountered = False + try: + _can_use_request(dataset_populator, request) + except Exception as e: + assert f"Request status code ({expect_status_code})" in str(e) + exception_encountered = True + assert exception_encountered, "Expected landing page usage to fail" diff --git a/lib/galaxy_test/base/populators.py b/lib/galaxy_test/base/populators.py index e2c9e1544971..f68321634984 100644 --- a/lib/galaxy_test/base/populators.py +++ b/lib/galaxy_test/base/populators.py @@ -820,6 +820,12 @@ def claim_workflow_landing(self, uuid: UUID4) -> WorkflowLandingRequest: api_asserts.assert_status_code_is(claim_response, 200) return WorkflowLandingRequest.model_validate(claim_response.json()) + def use_workflow_landing(self, uuid: UUID4) -> WorkflowLandingRequest: + url = f"workflow_landings/{uuid}" + landing_reponse = self._get(url, {"client_secret": "foobar"}) + api_asserts.assert_status_code_is(landing_reponse, 200) + return WorkflowLandingRequest.model_validate(landing_reponse.json()) + def create_tool_from_path(self, tool_path: str) -> Dict[str, Any]: tool_directory = os.path.dirname(os.path.abspath(tool_path)) payload = dict( diff --git a/lib/galaxy_test/selenium/test_workflow_landing.py b/lib/galaxy_test/selenium/test_workflow_landing.py new file mode 100644 index 000000000000..9927c383bb5e --- /dev/null +++ b/lib/galaxy_test/selenium/test_workflow_landing.py @@ -0,0 +1,63 @@ +from typing import Literal + +from galaxy.schema.schema import CreateWorkflowLandingRequestPayload +from .framework import ( + managed_history, + RunsWorkflows, + selenium_test, + SeleniumTestCase, +) + + +class TestWorkflowLanding(SeleniumTestCase, RunsWorkflows): + ensure_registered = True + + @selenium_test + @managed_history + def test_private_request(self): + self._create_landing_and_run(public="false") + + @selenium_test + @managed_history + def test_pubblic_request(self): + self._create_landing_and_run(public="true") + + def _create_landing_and_run(self, public: Literal["false", "true"]): + self.perform_upload(self.get_filename("1.txt")) + self.wait_for_history() + workflow_id = self.workflow_populator.upload_yaml_workflow( + """ +class: GalaxyWorkflow +inputs: + input_int: integer + input_data: data +steps: + simple_constructs: + tool_id: simple_constructs + label: tool_exec + in: + inttest: input_int + files_0|file: input_data +""", + name=self._get_random_name("landing_wf"), + ) + if public == "true": + client_secret = None + else: + client_secret = "abcdefg" + landing_request_payload = CreateWorkflowLandingRequestPayload( + workflow_id=workflow_id, + workflow_target_type="stored_workflow", + request_state={"input_int": 321123}, + public=public, + client_secret=client_secret, + ) + landing_request = self.dataset_populator.create_workflow_landing(landing_request_payload) + self.go_to_workflow_landing(str(landing_request.uuid), public="false", client_secret=client_secret) + self.screenshot("workflow_run_private_landing") + self.workflow_run_submit() + output_hid = 2 + self.workflow_run_wait_for_ok(hid=output_hid) + history_id = self.current_history_id() + content = self.dataset_populator.get_history_dataset_content(history_id, hid=output_hid) + assert "321123" in content, content diff --git a/test/unit/app/managers/test_landing.py b/test/unit/app/managers/test_landing.py index 211e1f8010e5..46762c110a6d 100644 --- a/test/unit/app/managers/test_landing.py +++ b/test/unit/app/managers/test_landing.py @@ -1,11 +1,14 @@ from uuid import uuid4 +from galaxy.config import GalaxyAppConfiguration from galaxy.exceptions import ( InsufficientPermissionsException, ItemAlreadyClaimedException, + ItemMustBeClaimed, ObjectNotFound, ) from galaxy.managers.landing import LandingRequestManager +from galaxy.managers.workflows import WorkflowContentsManager from galaxy.model import ( StoredWorkflow, Workflow, @@ -19,6 +22,7 @@ ToolLandingRequest, WorkflowLandingRequest, ) +from galaxy.workflow.trs_proxy import TrsProxy from .base import BaseTestCase TEST_TOOL_ID = "cat1" @@ -37,7 +41,11 @@ class TestLanding(BaseTestCase): def setUp(self): super().setUp() - self.landing_manager = LandingRequestManager(self.trans.sa_session, self.app.security) + self.workflow_contents_manager = WorkflowContentsManager(self.app) + self.landing_manager = LandingRequestManager( + self.trans.sa_session, self.app.security, self.workflow_contents_manager + ) + self.trans.app.trs_proxy = TrsProxy(GalaxyAppConfiguration(override_tempdir=False)) def test_tool_landing_requests_typical_flow(self): landing_request: ToolLandingRequest = self.landing_manager.create_tool_landing_request(self._tool_request) @@ -69,7 +77,7 @@ def test_tool_landing_requests_get_requires_claim(self): exception = None try: self.landing_manager.get_tool_landing_request(self.trans, uuid) - except InsufficientPermissionsException as e: + except ItemMustBeClaimed as e: exception = e assert exception is not None diff --git a/test/unit/workflows/test_trs_proxy.py b/test/unit/workflows/test_trs_proxy.py index 179d95625d80..0d07723a088e 100644 --- a/test/unit/workflows/test_trs_proxy.py +++ b/test/unit/workflows/test_trs_proxy.py @@ -4,6 +4,7 @@ import pytest import yaml +from galaxy.config import GalaxyAppConfiguration from galaxy.exceptions import ConfigDoesNotAllowException from galaxy.workflow.trs_proxy import ( GA4GH_GALAXY_DESCRIPTOR, @@ -18,8 +19,12 @@ ) +def get_trs_proxy(): + return TrsProxy(GalaxyAppConfiguration(fetch_url_allowlist_ips=[], override_tempdir=False)) + + def test_proxy(): - proxy = TrsProxy() + proxy = get_trs_proxy() server = proxy.get_server("dockstore") assert "dockstore" == proxy.get_servers()[0]["id"] @@ -49,7 +54,7 @@ def test_proxy(): def test_match_url(): - proxy = TrsProxy() + proxy = get_trs_proxy() valid_dockstore = proxy._match_url( "https://dockstore.org/api/ga4gh/trs/v2/tools/" "quay.io%2Fcollaboratory%2Fdockstore-tool-bedtools-genomecov/versions/0.3", @@ -115,7 +120,7 @@ def test_match_url(): def test_server_from_url(): - proxy = TrsProxy() + proxy = get_trs_proxy() server = proxy.server_from_url("https://workflowhub.eu") assert "https://workflowhub.eu" == server._trs_url @@ -144,7 +149,7 @@ def test_server_from_url(): @search_test def test_search(): - proxy = TrsProxy() + proxy = get_trs_proxy() server = proxy.get_server("dockstore") search_kwd = parse_search_kwds("documentation")