From 2e12cd080cb3c6458e596e56da0b48c71efd1448 Mon Sep 17 00:00:00 2001 From: PlushZ Date: Thu, 2 Apr 2026 18:25:08 +1100 Subject: [PATCH 1/7] Add OneDrive file source --- client/src/api/fileSources.ts | 4 + client/src/api/schema/schema.ts | 2 + .../FileSources/Instances/CreateInstance.vue | 2 +- lib/galaxy/files/sources/onedrive.py | 220 ++++++++++++++++++ .../examples/production_onedrive.yml | 15 ++ lib/galaxy/files/templates/models.py | 27 +++ 6 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 lib/galaxy/files/sources/onedrive.py create mode 100644 lib/galaxy/files/templates/examples/production_onedrive.yml diff --git a/client/src/api/fileSources.ts b/client/src/api/fileSources.ts index cdb9e2e1665..c420b9ed3d6 100644 --- a/client/src/api/fileSources.ts +++ b/client/src/api/fileSources.ts @@ -32,6 +32,10 @@ export const templateTypes: FileSourceTypesDetail = { icon: faGoogleDrive, message: "This is a repository plugin that connects with the commercial Google Drive service.", }, + onedrive: { + icon: faCloud, + message: "This is a repository plugin that connects with Microsoft OneDrive through Microsoft Graph.", + }, onedata: { icon: faNetworkWired, message: "This is a repository plugin based on the Onedata service.", diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 42dc2895eb1..10e3bb6d727 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -12873,6 +12873,7 @@ export interface components { | "webdav" | "dropbox" | "googledrive" + | "onedrive" | "elabftw" | "inveniordm" | "zenodo" @@ -24527,6 +24528,7 @@ export interface components { | "webdav" | "dropbox" | "googledrive" + | "onedrive" | "elabftw" | "inveniordm" | "zenodo" diff --git a/client/src/components/FileSources/Instances/CreateInstance.vue b/client/src/components/FileSources/Instances/CreateInstance.vue index 7c48e421d6f..68519346f94 100644 --- a/client/src/components/FileSources/Instances/CreateInstance.vue +++ b/client/src/components/FileSources/Instances/CreateInstance.vue @@ -18,7 +18,7 @@ interface Props { uuid?: string; } -const OAUTH2_TYPES = ["dropbox", "googledrive"]; +const OAUTH2_TYPES = ["dropbox", "googledrive", "onedrive"]; const fileSourceTemplatesStore = useFileSourceTemplatesStore(); fileSourceTemplatesStore.fetchTemplates(); diff --git a/lib/galaxy/files/sources/onedrive.py b/lib/galaxy/files/sources/onedrive.py new file mode 100644 index 00000000000..05076548504 --- /dev/null +++ b/lib/galaxy/files/sources/onedrive.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from typing import ( + Annotated, + Optional, + Union, +) +from urllib.parse import quote + +import requests +from pydantic import ( + AliasChoices, + Field, +) + +from galaxy.exceptions import ( + AuthenticationRequired, + MessageException, + RequestParameterInvalidException, +) +from galaxy.files.models import ( + AnyRemoteEntry, + BaseFileSourceConfiguration, + BaseFileSourceTemplateConfiguration, + Entry, + EntryData, + FilesSourceRuntimeContext, + RemoteDirectory, + RemoteFile, +) +from galaxy.util.config_templates import TemplateExpansion +from . import BaseFilesSource + +AccessTokenField = Field( + ..., + title="Access Token", + description="The OAuth2 access token for Microsoft Graph.", + validation_alias=AliasChoices("oauth2_access_token", "accessToken", "access_token"), +) + + +class OneDriveFileSourceTemplateConfiguration(BaseFileSourceTemplateConfiguration): + access_token: Annotated[Union[str, TemplateExpansion], AccessTokenField] + drive_api_base: Union[str, TemplateExpansion] = "https://graph.microsoft.com/v1.0/me/drive" + + +class OneDriveFilesSourceConfiguration(BaseFileSourceConfiguration): + access_token: Annotated[str, AccessTokenField] + drive_api_base: str = "https://graph.microsoft.com/v1.0/me/drive" + + +class OneDriveFilesSource( + BaseFilesSource[OneDriveFileSourceTemplateConfiguration, OneDriveFilesSourceConfiguration] +): + plugin_type = "onedrive" + + template_config_class = OneDriveFileSourceTemplateConfiguration + resolved_config_class = OneDriveFilesSourceConfiguration + + def _headers(self, config: OneDriveFilesSourceConfiguration) -> dict[str, str]: + return { + "Authorization": f"Bearer {config.access_token}", + } + + def _json_headers(self, config: OneDriveFilesSourceConfiguration) -> dict[str, str]: + headers = self._headers(config) + headers["Content-Type"] = "application/json" + return headers + + def _encoded_path(self, path: str) -> str: + normalized = path.strip("/") + if not normalized: + return "" + return "/".join(quote(component, safe="") for component in normalized.split("/")) + + def _item_url(self, config: OneDriveFilesSourceConfiguration, path: str) -> str: + api_base = config.drive_api_base.rstrip("/") + encoded_path = self._encoded_path(path) + if encoded_path: + return f"{api_base}/special/approot:/{encoded_path}" + return f"{api_base}/special/approot" + + def _children_url(self, config: OneDriveFilesSourceConfiguration, path: str) -> str: + return f"{self._item_url(config, path)}/children" + + def _content_url(self, config: OneDriveFilesSourceConfiguration, path: str) -> str: + return f"{self._item_url(config, path)}:/content" if path.strip("/") else f"{self._item_url(config, path)}/content" + + def _request( + self, + method: str, + url: str, + context: FilesSourceRuntimeContext[OneDriveFilesSourceConfiguration], + **kwargs, + ) -> requests.Response: + try: + response = requests.request(method, url, headers=self._headers(context.config), timeout=30, **kwargs) + except requests.RequestException as exc: + raise MessageException(f"Error connecting to OneDrive. Reason: {exc}") from exc + + if response.status_code in {401, 403}: + raise AuthenticationRequired( + "Permission denied while accessing OneDrive. Check the Microsoft app registration, granted scopes, and the stored user authorization." + ) + if response.status_code == 404: + raise RequestParameterInvalidException(f"Path not found in OneDrive: {url}") + if not response.ok: + try: + payload = response.json() + message = payload.get("error", {}).get("message", response.text) + except Exception: + message = response.text + raise MessageException(f"Error communicating with OneDrive. Reason: {message}") + return response + + def _entry_from_item( + self, + item: dict, + parent_path: str, + ) -> AnyRemoteEntry: + relative_parent = parent_path.rstrip("/") + relative_path = f"{relative_parent}/{item['name']}".replace("//", "/") + if not relative_path.startswith("/"): + relative_path = f"/{relative_path}" + uri = self.uri_from_path(relative_path) + if "folder" in item: + return RemoteDirectory( + name=item["name"], + uri=uri, + path=relative_path, + ) + return RemoteFile( + name=item["name"], + uri=uri, + path=relative_path, + size=item.get("size", 0), + ctime=item.get("lastModifiedDateTime"), + ) + + def _list( + self, + context: FilesSourceRuntimeContext[OneDriveFilesSourceConfiguration], + path: str = "/", + recursive: bool = False, + write_intent: bool = False, + limit: Optional[int] = None, + offset: Optional[int] = None, + query: Optional[str] = None, + sort_by: Optional[str] = None, + ) -> tuple[list[AnyRemoteEntry], int]: + response = self._request("GET", self._children_url(context.config, path), context) + items = response.json().get("value", []) + entries = [self._entry_from_item(item, path) for item in items] + return entries, len(entries) + + def _realize_to( + self, source_path: str, native_path: str, context: FilesSourceRuntimeContext[OneDriveFilesSourceConfiguration] + ): + response = self._request("GET", self._content_url(context.config, source_path), context, stream=True) + with open(native_path, "wb") as out: + for chunk in response.iter_content(chunk_size=1024 * 1024): + if chunk: + out.write(chunk) + + def _write_from( + self, target_path: str, native_path: str, context: FilesSourceRuntimeContext[OneDriveFilesSourceConfiguration] + ) -> str: + upload_url = self._content_url(context.config, target_path) + with open(native_path, "rb") as handle: + response = requests.put( + upload_url, + headers={"Authorization": f"Bearer {context.config.access_token}", "Content-Type": "application/octet-stream"}, + data=handle, + timeout=300, + ) + if response.status_code in {401, 403}: + raise AuthenticationRequired( + "Permission denied while writing to OneDrive. Check the Microsoft app scopes and stored user authorization." + ) + if not response.ok: + try: + payload = response.json() + message = payload.get("error", {}).get("message", response.text) + except Exception: + message = response.text + raise MessageException(f"Error uploading to OneDrive. Reason: {message}") + return self.uri_from_path(target_path) + + def _create_entry( + self, entry_data: EntryData, context: FilesSourceRuntimeContext[OneDriveFilesSourceConfiguration] + ) -> Entry: + parent_path = getattr(entry_data, "path", "/") + payload = { + "name": entry_data.name, + "folder": {}, + "@microsoft.graph.conflictBehavior": "fail", + } + response = requests.post( + self._children_url(context.config, parent_path), + json=payload, + headers=self._json_headers(context.config), + timeout=30, + ) + if response.status_code in {401, 403}: + raise AuthenticationRequired( + "Permission denied while creating a OneDrive folder. Check the Microsoft app scopes and stored user authorization." + ) + if not response.ok: + try: + body = response.json() + message = body.get("error", {}).get("message", response.text) + except Exception: + message = response.text + raise MessageException(f"Error creating OneDrive folder. Reason: {message}") + item = response.json() + path = self._entry_from_item(item, parent_path).path + return Entry(name=item["name"], uri=self.uri_from_path(path), external_link=item.get("webUrl")) + + +__all__ = ("OneDriveFilesSource",) diff --git a/lib/galaxy/files/templates/examples/production_onedrive.yml b/lib/galaxy/files/templates/examples/production_onedrive.yml new file mode 100644 index 00000000000..24d58fde67c --- /dev/null +++ b/lib/galaxy/files/templates/examples/production_onedrive.yml @@ -0,0 +1,15 @@ +- id: onedrive + name: OneDrive + description: Connect to your Microsoft OneDrive app folder to download and upload files. + configuration: + type: onedrive + oauth2_client_id: "{{ environment.oauth2_client_id }}" + oauth2_client_secret: "{{ environment.oauth2_client_secret }}" + writable: true + environment: + oauth2_client_id: + type: variable + variable: GALAXY_ONEDRIVE_CLIENT_ID + oauth2_client_secret: + type: variable + variable: GALAXY_ONEDRIVE_CLIENT_SECRET diff --git a/lib/galaxy/files/templates/models.py b/lib/galaxy/files/templates/models.py index ed9adc73f2d..cae6152c95c 100644 --- a/lib/galaxy/files/templates/models.py +++ b/lib/galaxy/files/templates/models.py @@ -41,6 +41,7 @@ FileSourceTemplateType = Literal[ "webdav", "dropbox", "googledrive", + "onedrive", "elabftw", "inveniordm", "zenodo", @@ -113,6 +114,23 @@ class GoogleDriveFileSourceConfiguration(OAuth2FileSourceConfiguration, StrictMo oauth2_access_token: str +class OneDriveFileSourceTemplateConfiguration(OAuth2TemplateConfiguration, StrictModel): + type: Literal["onedrive"] + writable: Union[bool, TemplateExpansion] = False + oauth2_client_id: Union[str, TemplateExpansion] + oauth2_client_secret: Union[str, TemplateExpansion] + # Microsoft Graph app-folder scope keeps access limited to Apps/. + oauth2_scope: Optional[Union[str, TemplateExpansion]] = None + template_start: Optional[str] = None + template_end: Optional[str] = None + + +class OneDriveFileSourceConfiguration(OAuth2FileSourceConfiguration, StrictModel): + type: Literal["onedrive"] + writable: bool = False + oauth2_access_token: str + + class S3FSFileSourceTemplateConfiguration(StrictModel): type: Literal["s3fs"] endpoint_url: Optional[Union[str, TemplateExpansion]] = None @@ -362,6 +380,7 @@ FileSourceTemplateConfiguration = Annotated[ WebdavFileSourceTemplateConfiguration, DropboxFileSourceTemplateConfiguration, GoogleDriveFileSourceTemplateConfiguration, + OneDriveFileSourceTemplateConfiguration, eLabFTWFileSourceTemplateConfiguration, InvenioFileSourceTemplateConfiguration, ZenodoFileSourceTemplateConfiguration, @@ -384,6 +403,7 @@ FileSourceConfiguration = Annotated[ WebdavFileSourceConfiguration, DropboxFileSourceConfiguration, GoogleDriveFileSourceConfiguration, + OneDriveFileSourceConfiguration, eLabFTWFileSourceConfiguration, InvenioFileSourceConfiguration, ZenodoFileSourceConfiguration, @@ -464,6 +484,7 @@ TypesToConfigurationClasses: dict[FileSourceTemplateType, type[FileSourceConfigu "webdav": WebdavFileSourceConfiguration, "dropbox": DropboxFileSourceConfiguration, "googledrive": GoogleDriveFileSourceConfiguration, + "onedrive": OneDriveFileSourceConfiguration, "elabftw": eLabFTWFileSourceConfiguration, "inveniordm": InvenioFileSourceConfiguration, "zenodo": ZenodoFileSourceConfiguration, @@ -486,6 +507,12 @@ OAUTH2_CONFIGURED_SOURCES: ConfiguredOAuth2Sources = { token_url="https://oauth2.googleapis.com/token", scope="https://www.googleapis.com/auth/drive.file", ), + "onedrive": OAuth2Configuration( + authorize_url="https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + token_url="https://login.microsoftonline.com/common/oauth2/v2.0/token", + authorize_params={}, + scope="offline_access Files.ReadWrite.AppFolder", + ), } From 534161268734ec9ef966d3103ce3d60da4762dcc Mon Sep 17 00:00:00 2001 From: PlushZ Date: Thu, 2 Apr 2026 18:25:08 +1100 Subject: [PATCH 2/7] Add OneDrive file source --- doc/source/admin/data.md | 48 +++++ lib/galaxy/files/sources/onedrive.py | 14 ++ .../app/managers/test_user_file_sources.py | 111 ++++++++++- test/unit/files/test_onedrive.py | 177 ++++++++++++++++++ 4 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 test/unit/files/test_onedrive.py diff --git a/doc/source/admin/data.md b/doc/source/admin/data.md index fe973dcf75f..ae2449274c4 100644 --- a/doc/source/admin/data.md +++ b/doc/source/admin/data.md @@ -626,6 +626,54 @@ a production Galaxy instance but Dropbox operates on a different scale. For more information on what Dropbox considers a "development" app versus a "production" app - checkout the [Dropbox documentation](https://www.dropbox.com/developers/reference/developer-guide#production-approval). +#### OneDrive + +Once you have OAuth 2.0 client credentials from Microsoft Entra (called `oauth2_client_id` +and `oauth2_client_secret` here), the following configuration can be used to enable +OneDrive for your Galaxy instance. + +```{literalinclude} ../../../lib/galaxy/files/templates/examples/production_onedrive.yml +:language: yaml +``` + +To use this template, make the credentials available to Galaxy's web and job handler +processes using the environment variables `GALAXY_ONEDRIVE_CLIENT_ID` and +`GALAXY_ONEDRIVE_CLIENT_SECRET`. Jobs themselves do not need these values and should +not receive them. + +The current OneDrive implementation targets Microsoft Graph special/approot and uses the +`Files.ReadWrite.AppFolder` delegated scope. This means Galaxy can browse, download, +upload, and create folders inside the application's dedicated OneDrive app folder +(`Apps/`). Supporting full-drive access would require code changes +in addition to broader scopes such as `Files.ReadWrite` or `Files.ReadWrite.All` and +configuring yml template with `oauth2_scope: "offline_access Files.ReadWrite.All"` + +To configure Microsoft Entra for this file source: + +1. Create an app registration for your Galaxy instance in Microsoft Entra. +2. Add a web redirect URI pointing to your Galaxy instance with `oauth2_callback` + appended to the root URL. For local development, this is typically + `http://localhost:8080/oauth2_callback`. +3. Configure supported account types for the accounts you intend to support. If you + want to support both work/school accounts and personal Microsoft accounts, set + the audience to "Any Entra ID tenant + Personal Microsoft accounts". +4. Add delegated Microsoft Graph permissions: `Files.ReadWrite.AppFolder`, + `offline_access` (to ensure Galaxy can obtain refresh tokens for long-lived access). +5. Create a client secret and provide its value to Galaxy via + `GALAXY_ONEDRIVE_CLIENT_SECRET` (remember, this value can be seen only once after creation). +6. Go to Overview, find "Application (client) ID" and provide its value to Galaxy + via `GALAXY_ONEDRIVE_CLIENT_ID` + +The OAuth2 endpoints Galaxy uses for this template are the Microsoft identity platform's +v2 endpoints under the `common` tenant. If you register an application that also supports +personal Microsoft accounts, ensure the manifest is consistent with that audience. In +particular, the app manifest should use `AzureADandPersonalMicrosoftAccount` as the +`signInAudience` and `2` as the `requestedAccessTokenVersion`. + +This first implementation currently uses Microsoft Graph's simple upload endpoint and +does not yet implement resumable uploads for very large files, server-side pagination, +or server-side search/sorting. + ## Playing Nicer with Ansible Many large instances of Galaxy are configured with Ansible and much of the existing administrator diff --git a/lib/galaxy/files/sources/onedrive.py b/lib/galaxy/files/sources/onedrive.py index 05076548504..db1da53ad88 100644 --- a/lib/galaxy/files/sources/onedrive.py +++ b/lib/galaxy/files/sources/onedrive.py @@ -81,7 +81,14 @@ class OneDriveFilesSource( return f"{api_base}/special/approot" def _children_url(self, config: OneDriveFilesSourceConfiguration, path: str) -> str: +<<<<<<< HEAD return f"{self._item_url(config, path)}/children" +======= + item_url = self._item_url(config, path) + if path.strip("/"): + return f"{item_url}:/children" + return f"{item_url}/children" +>>>>>>> 4132e5d794 (Add OneDrive file source) def _content_url(self, config: OneDriveFilesSourceConfiguration, path: str) -> str: return f"{self._item_url(config, path)}:/content" if path.strip("/") else f"{self._item_url(config, path)}/content" @@ -189,7 +196,14 @@ class OneDriveFilesSource( def _create_entry( self, entry_data: EntryData, context: FilesSourceRuntimeContext[OneDriveFilesSourceConfiguration] ) -> Entry: +<<<<<<< HEAD parent_path = getattr(entry_data, "path", "/") +======= + parent_path = getattr(entry_data, "path", None) + if parent_path is None: + target = getattr(entry_data, "target", "/") + parent_path = self.to_relative_path(target) +>>>>>>> 4132e5d794 (Add OneDrive file source) payload = { "name": entry_data.name, "folder": {}, diff --git a/test/unit/app/managers/test_user_file_sources.py b/test/unit/app/managers/test_user_file_sources.py index c0aec084be3..83e172f5c82 100644 --- a/test/unit/app/managers/test_user_file_sources.py +++ b/test/unit/app/managers/test_user_file_sources.py @@ -23,7 +23,10 @@ from galaxy.exceptions import ( RequestParameterMissingException, ) from galaxy.files import FileSourcesUserContext -from galaxy.files.sources import dropbox +from galaxy.files.sources import ( + dropbox, + onedrive, +) from galaxy.files.templates import ConfiguredFileSourceTemplates from galaxy.files.templates.examples import get_example from galaxy.managers._config_templates import prepare_environment_from_root @@ -334,6 +337,90 @@ class TestFileSourcesTestCase(BaseTestCase): assert not status.connection.is_not_ok assert pyfilesystem_fs_init_kwd["access_token"] == "my_test_access_token" + def test_onedrive_oauth2_flow(self, tmp_path, monkeypatch): + json = { + "refresh_token": "my_test_refresh_token", + } + + def mock_get_token_from_code_raw( + code, + client_pair, + config, + redirect_uri, + ): + return MockResponse(json) + + monkeypatch.setattr(config_templates, "get_token_from_code_raw", mock_get_token_from_code_raw) + + self._init_onedrive_env(tmp_path, monkeypatch) + + authorize_url = self.manager.template_oauth2(self.trans, "onedrive", 0).authorize_url + from urllib.parse import ( + parse_qs, + urlparse, + ) + + parse_result = urlparse(authorize_url) + assert parse_result.hostname == "login.microsoftonline.com" + assert parse_result.path == "/common/oauth2/v2.0/authorize" + query_params = parse_qs(parse_result.query) + assert query_params["scope"][0] == "offline_access Files.ReadWrite.AppFolder" + assert "state" in query_params + state_param = query_params["state"] + state = OAuth2State.decode(state_param[0]) + assert state.route == "file_source_instances/onedrive/0" + redirect_url = self.manager.handle_authorization_code( + self.trans, + "moocow", + state, + ) + parse_result = urlparse(redirect_url) + query_params = parse_qs(parse_result.query) + assert "uuid" in query_params + uuid = query_params["uuid"][0] + + user_vault = self.trans.user_vault + config_secret_key = UserFileSource.vault_key_from_uuid(uuid, "_oauth2_refresh_token", None) + assert user_vault.read_secret(config_secret_key) + + def test_onedrive_oauth2_access_token_injection_during_verify(self, tmp_path, monkeypatch): + self._init_onedrive_env(tmp_path, monkeypatch) + + uuid = uuid4().hex + user_vault = self.trans.user_vault + config_secret_key = UserFileSource.vault_key_from_uuid(uuid, "_oauth2_refresh_token", None) + user_vault.write_secret(config_secret_key, "test_refresh_token") + create_payload = CreateInstancePayload( + name=SIMPLE_FILE_SOURCE_NAME, + description=SIMPLE_FILE_SOURCE_DESCRIPTION, + template_id="onedrive", + template_version=0, + variables={}, + secrets={}, + uuid=uuid, + ) + self._create_instance(create_payload) + json = { + "access_token": "my_test_access_token", + } + observed_headers = {} + + def mock_get_token_from_refresh_raw(refresh_token, client_pair, config): + return MockResponse(json) + + def mock_request(method, url, headers=None, timeout=None, **kwargs): + observed_headers.update(headers or {}) + return OneDriveMockResponse(json_data={"value": []}) + + monkeypatch.setattr(config_templates, "get_token_from_refresh_raw", mock_get_token_from_refresh_raw) + monkeypatch.setattr(onedrive.requests, "request", mock_request) + status = self.manager.plugin_status(self.trans, create_payload) + assert status.oauth2_access_token_generation + assert not status.oauth2_access_token_generation.is_not_ok + assert status.connection + assert not status.connection.is_not_ok + assert observed_headers["Authorization"] == "Bearer my_test_access_token" + def test_report_oauth2_access_token_generation_failure(self, tmp_path, monkeypatch): self._init_dropbox_env(tmp_path, monkeypatch) @@ -855,6 +942,13 @@ class TestFileSourcesTestCase(BaseTestCase): monkeypatch.setenv("GALAXY_DROPBOX_APP_CLIENT_ID", "mock_client_id") monkeypatch.setenv("GALAXY_DROPBOX_APP_CLIENT_SECRET", "mock_client_secret") + def _init_onedrive_env(self, tmp_path, monkeypatch): + self.init_user_in_database() + self._init_managers(tmp_path, safe_load(get_example("production_onedrive.yml"))) + + monkeypatch.setenv("GALAXY_ONEDRIVE_CLIENT_ID", "mock_client_id") + monkeypatch.setenv("GALAXY_ONEDRIVE_CLIENT_SECRET", "mock_client_secret") + def _create_user_file_source(self, template_id="home_directory") -> UserFileSourceModel: create_payload = CreateInstancePayload( name=SIMPLE_FILE_SOURCE_NAME, @@ -943,3 +1037,18 @@ class MockExceptionResponse: def raise_for_status(self): raise HTTPError(self._exception_msg, self._exception_msg, response=None) + + +class OneDriveMockResponse: + + def __init__(self, status_code=200, json_data=None, text=""): + self.status_code = status_code + self._json_data = json_data or {} + self.text = text + + @property + def ok(self): + return 200 <= self.status_code < 300 + + def json(self): + return self._json_data diff --git a/test/unit/files/test_onedrive.py b/test/unit/files/test_onedrive.py new file mode 100644 index 00000000000..9aad56196f6 --- /dev/null +++ b/test/unit/files/test_onedrive.py @@ -0,0 +1,177 @@ +from pathlib import Path + +import pytest + +from galaxy.exceptions import ( + AuthenticationRequired, + MessageException, +) +from galaxy.files.models import ( + EntryData, + FileSourcePluginsConfig, +) +from galaxy.files.sources.onedrive import OneDriveFilesSource + + +class MockResponse: + def __init__(self, status_code=200, json_data=None, text="", content_chunks=None): + self.status_code = status_code + self._json_data = json_data or {} + self.text = text + self._content_chunks = content_chunks or [] + + @property + def ok(self): + return 200 <= self.status_code < 300 + + def json(self): + return self._json_data + + def iter_content(self, chunk_size=1024 * 1024): + yield from self._content_chunks + + +def _plugin(): + template = OneDriveFilesSource.build_template_config( + id="test1", + type="onedrive", + label="OneDrive", + doc="Test OneDrive file source", + writable=True, + access_token="test_access_token", + file_sources_config=FileSourcePluginsConfig(), + ) + return OneDriveFilesSource(template) + + +def test_list_root(monkeypatch): + plugin = _plugin() + observed = {} + + def mock_request(method, url, headers=None, timeout=None, **kwargs): + observed["method"] = method + observed["url"] = url + observed["headers"] = headers + return MockResponse( + json_data={ + "value": [ + {"name": "subdir", "folder": {}, "size": 0}, + {"name": "a.txt", "size": 12, "lastModifiedDateTime": "2026-04-05T12:00:00Z"}, + ] + } + ) + + monkeypatch.setattr("galaxy.files.sources.onedrive.requests.request", mock_request) + + entries, count = plugin.list("/") + + assert count == 2 + assert observed["method"] == "GET" + assert observed["url"] == "https://graph.microsoft.com/v1.0/me/drive/special/approot/children" + assert observed["headers"]["Authorization"] == "Bearer test_access_token" + assert entries[0].path == "/subdir" + assert entries[0].uri == "gxfiles://test1/subdir" + assert entries[1].path == "/a.txt" + assert entries[1].uri == "gxfiles://test1/a.txt" + + +def test_list_nested_folder(monkeypatch): + plugin = _plugin() + observed = {} + + def mock_request(method, url, headers=None, timeout=None, **kwargs): + observed["url"] = url + return MockResponse(json_data={"value": [{"name": "b.txt", "size": 4}]}) + + monkeypatch.setattr("galaxy.files.sources.onedrive.requests.request", mock_request) + + entries, count = plugin.list("/level1/level 2") + + assert count == 1 + assert observed["url"] == "https://graph.microsoft.com/v1.0/me/drive/special/approot:/level1/level%202:/children" + assert entries[0].path == "/level1/level 2/b.txt" + + +def test_realize_to_downloads_content(monkeypatch, tmp_path: Path): + plugin = _plugin() + target = tmp_path / "downloaded.txt" + + def mock_request(method, url, headers=None, timeout=None, stream=None, **kwargs): + assert method == "GET" + assert stream is True + assert url == "https://graph.microsoft.com/v1.0/me/drive/special/approot:/dir/file.txt:/content" + return MockResponse(content_chunks=[b"hello ", b"world"]) + + monkeypatch.setattr("galaxy.files.sources.onedrive.requests.request", mock_request) + + plugin.realize_to("/dir/file.txt", str(target)) + + assert target.read_text("utf-8") == "hello world" + + +def test_write_from_uploads_content(monkeypatch, tmp_path: Path): + plugin = _plugin() + source = tmp_path / "upload.txt" + source.write_text("payload", "utf-8") + observed = {} + + def mock_put(url, headers=None, data=None, timeout=None): + observed["url"] = url + observed["headers"] = headers + observed["data"] = data.read() + return MockResponse(json_data={"id": "item1"}) + + monkeypatch.setattr("galaxy.files.sources.onedrive.requests.put", mock_put) + + actual_uri = plugin.write_from("/nested/upload.txt", str(source)) + + assert actual_uri == "gxfiles://test1/nested/upload.txt" + assert observed["url"] == "https://graph.microsoft.com/v1.0/me/drive/special/approot:/nested/upload.txt:/content" + assert observed["headers"]["Authorization"] == "Bearer test_access_token" + assert observed["data"] == b"payload" + + +def test_create_entry_creates_directory(monkeypatch): + plugin = _plugin() + observed = {} + + def mock_post(url, json=None, headers=None, timeout=None): + observed["url"] = url + observed["json"] = json + observed["headers"] = headers + return MockResponse(json_data={"name": "newdir", "webUrl": "https://example.org/newdir"}) + + monkeypatch.setattr("galaxy.files.sources.onedrive.requests.post", mock_post) + + entry = plugin.create_entry(EntryData(name="newdir", target="gxfiles://test1/parent")) + + assert observed["url"] == "https://graph.microsoft.com/v1.0/me/drive/special/approot:/parent:/children" + assert observed["json"]["name"] == "newdir" + assert entry.uri == "gxfiles://test1/parent/newdir" + assert entry.external_link == "https://example.org/newdir" + + +def test_list_authentication_error(monkeypatch): + plugin = _plugin() + + def mock_request(method, url, headers=None, timeout=None, **kwargs): + return MockResponse(status_code=401, json_data={"error": {"message": "Unauthorized"}}) + + monkeypatch.setattr("galaxy.files.sources.onedrive.requests.request", mock_request) + + with pytest.raises(AuthenticationRequired): + plugin.list("/") + + +def test_write_reports_api_error(monkeypatch, tmp_path: Path): + plugin = _plugin() + source = tmp_path / "upload.txt" + source.write_text("payload", "utf-8") + + def mock_put(url, headers=None, data=None, timeout=None): + return MockResponse(status_code=400, json_data={"error": {"message": "Bad request"}}) + + monkeypatch.setattr("galaxy.files.sources.onedrive.requests.put", mock_put) + + with pytest.raises(MessageException, match="Bad request"): + plugin.write_from("/upload.txt", str(source)) From 9730b68b03d01cb4fa6e0262b8aa56e4930748da Mon Sep 17 00:00:00 2001 From: PlushZ Date: Sun, 5 Apr 2026 12:15:20 +1000 Subject: [PATCH 3/7] Resolve remaining merge markers --- lib/galaxy/files/sources/onedrive.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lib/galaxy/files/sources/onedrive.py b/lib/galaxy/files/sources/onedrive.py index db1da53ad88..9548bb21e85 100644 --- a/lib/galaxy/files/sources/onedrive.py +++ b/lib/galaxy/files/sources/onedrive.py @@ -81,14 +81,10 @@ class OneDriveFilesSource( return f"{api_base}/special/approot" def _children_url(self, config: OneDriveFilesSourceConfiguration, path: str) -> str: -<<<<<<< HEAD - return f"{self._item_url(config, path)}/children" -======= item_url = self._item_url(config, path) if path.strip("/"): return f"{item_url}:/children" return f"{item_url}/children" ->>>>>>> 4132e5d794 (Add OneDrive file source) def _content_url(self, config: OneDriveFilesSourceConfiguration, path: str) -> str: return f"{self._item_url(config, path)}:/content" if path.strip("/") else f"{self._item_url(config, path)}/content" @@ -196,14 +192,10 @@ class OneDriveFilesSource( def _create_entry( self, entry_data: EntryData, context: FilesSourceRuntimeContext[OneDriveFilesSourceConfiguration] ) -> Entry: -<<<<<<< HEAD - parent_path = getattr(entry_data, "path", "/") -======= parent_path = getattr(entry_data, "path", None) if parent_path is None: target = getattr(entry_data, "target", "/") parent_path = self.to_relative_path(target) ->>>>>>> 4132e5d794 (Add OneDrive file source) payload = { "name": entry_data.name, "folder": {}, From abfbf02517fb580ee7b7b902373618178b609767 Mon Sep 17 00:00:00 2001 From: PlushZ Date: Sun, 5 Apr 2026 16:06:42 +1000 Subject: [PATCH 4/7] add full-access drive mode, template and tests --- doc/source/admin/data.md | 38 ++++++++++----- lib/galaxy/files/sources/onedrive.py | 30 ++++++++---- .../examples/production_onedrive.yml | 1 + .../examples/production_onedrive_full.yml | 17 +++++++ lib/galaxy/files/templates/models.py | 2 + .../app/managers/test_user_file_sources.py | 9 ++-- test/unit/files/test_onedrive.py | 48 +++++++++++++++++++ 7 files changed, 120 insertions(+), 25 deletions(-) create mode 100644 lib/galaxy/files/templates/examples/production_onedrive_full.yml diff --git a/doc/source/admin/data.md b/doc/source/admin/data.md index ae2449274c4..c118ac78853 100644 --- a/doc/source/admin/data.md +++ b/doc/source/admin/data.md @@ -641,12 +641,17 @@ processes using the environment variables `GALAXY_ONEDRIVE_CLIENT_ID` and `GALAXY_ONEDRIVE_CLIENT_SECRET`. Jobs themselves do not need these values and should not receive them. -The current OneDrive implementation targets Microsoft Graph special/approot and uses the -`Files.ReadWrite.AppFolder` delegated scope. This means Galaxy can browse, download, -upload, and create folders inside the application's dedicated OneDrive app folder -(`Apps/`). Supporting full-drive access would require code changes -in addition to broader scopes such as `Files.ReadWrite` or `Files.ReadWrite.All` and -configuring yml template with `oauth2_scope: "offline_access Files.ReadWrite.All"` +The current OneDrive implementation supports two drive modes: + +- `drive_mode: appfolder` + This is the default and targets Microsoft Graph `special/approot`. Galaxy can + browse, download, upload, and create folders inside the application's dedicated + OneDrive app folder (`Apps/`). This mode should be paired + with delegated permission `Files.ReadWrite.AppFolder`. +- `drive_mode: full` + This targets the user's full OneDrive root (`/me/drive/root`) instead of the + application folder. This mode requires broader delegated Microsoft Graph + permissions such as `Files.ReadWrite`. To configure Microsoft Entra for this file source: @@ -657,20 +662,31 @@ To configure Microsoft Entra for this file source: 3. Configure supported account types for the accounts you intend to support. If you want to support both work/school accounts and personal Microsoft accounts, set the audience to "Any Entra ID tenant + Personal Microsoft accounts". -4. Add delegated Microsoft Graph permissions: `Files.ReadWrite.AppFolder`, - `offline_access` (to ensure Galaxy can obtain refresh tokens for long-lived access). -5. Create a client secret and provide its value to Galaxy via +4. Add delegated Microsoft Graph permissions: + For the default app-folder setup, add permission `Files.ReadWrite.AppFolder`. + To ensure Galaxy can obtain refresh tokens for long-lived access add permission + `offline_access`. +6. Create a client secret and provide its value to Galaxy via `GALAXY_ONEDRIVE_CLIENT_SECRET` (remember, this value can be seen only once after creation). -6. Go to Overview, find "Application (client) ID" and provide its value to Galaxy +7. Go to Overview, find "Application (client) ID" and provide its value to Galaxy via `GALAXY_ONEDRIVE_CLIENT_ID` +To configure full-drive access instead of the default app-folder mode, you need to +change both the Galaxy yml config template and the Microsoft Entra app registration. +In Galaxy yml config, set `drive_mode: full` and request a broader OAuth scope such as +`oauth2_scope: "offline_access Files.ReadWrite"`. In Microsoft Entra, grant the +matching delegated Microsoft Graph permission (`Files.ReadWrite` instead of +`Files.ReadWrite.AppFolder`). If only the Microsoft permission is widened and +`drive_mode` remains `appfolder`, Galaxy will continue to operate only inside the +application folder. + The OAuth2 endpoints Galaxy uses for this template are the Microsoft identity platform's v2 endpoints under the `common` tenant. If you register an application that also supports personal Microsoft accounts, ensure the manifest is consistent with that audience. In particular, the app manifest should use `AzureADandPersonalMicrosoftAccount` as the `signInAudience` and `2` as the `requestedAccessTokenVersion`. -This first implementation currently uses Microsoft Graph's simple upload endpoint and +This implementation currently uses Microsoft Graph's simple upload endpoint and does not yet implement resumable uploads for very large files, server-side pagination, or server-side search/sorting. diff --git a/lib/galaxy/files/sources/onedrive.py b/lib/galaxy/files/sources/onedrive.py index 9548bb21e85..70a97cd8ad5 100644 --- a/lib/galaxy/files/sources/onedrive.py +++ b/lib/galaxy/files/sources/onedrive.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import ( Annotated, + Literal, Optional, Union, ) @@ -38,20 +39,22 @@ AccessTokenField = Field( validation_alias=AliasChoices("oauth2_access_token", "accessToken", "access_token"), ) +DriveMode = Literal["appfolder", "full"] + class OneDriveFileSourceTemplateConfiguration(BaseFileSourceTemplateConfiguration): access_token: Annotated[Union[str, TemplateExpansion], AccessTokenField] drive_api_base: Union[str, TemplateExpansion] = "https://graph.microsoft.com/v1.0/me/drive" + drive_mode: Union[DriveMode, TemplateExpansion] = "appfolder" class OneDriveFilesSourceConfiguration(BaseFileSourceConfiguration): access_token: Annotated[str, AccessTokenField] drive_api_base: str = "https://graph.microsoft.com/v1.0/me/drive" + drive_mode: DriveMode = "appfolder" -class OneDriveFilesSource( - BaseFilesSource[OneDriveFileSourceTemplateConfiguration, OneDriveFilesSourceConfiguration] -): +class OneDriveFilesSource(BaseFilesSource[OneDriveFileSourceTemplateConfiguration, OneDriveFilesSourceConfiguration]): plugin_type = "onedrive" template_config_class = OneDriveFileSourceTemplateConfiguration @@ -73,12 +76,18 @@ class OneDriveFilesSource( return "" return "/".join(quote(component, safe="") for component in normalized.split("/")) - def _item_url(self, config: OneDriveFilesSourceConfiguration, path: str) -> str: + def _root_url(self, config: OneDriveFilesSourceConfiguration) -> str: api_base = config.drive_api_base.rstrip("/") + if config.drive_mode == "full": + return f"{api_base}/root" + return f"{api_base}/special/approot" + + def _item_url(self, config: OneDriveFilesSourceConfiguration, path: str) -> str: + root_url = self._root_url(config) encoded_path = self._encoded_path(path) if encoded_path: - return f"{api_base}/special/approot:/{encoded_path}" - return f"{api_base}/special/approot" + return f"{root_url}:/{encoded_path}" + return root_url def _children_url(self, config: OneDriveFilesSourceConfiguration, path: str) -> str: item_url = self._item_url(config, path) @@ -87,7 +96,9 @@ class OneDriveFilesSource( return f"{item_url}/children" def _content_url(self, config: OneDriveFilesSourceConfiguration, path: str) -> str: - return f"{self._item_url(config, path)}:/content" if path.strip("/") else f"{self._item_url(config, path)}/content" + return ( + f"{self._item_url(config, path)}:/content" if path.strip("/") else f"{self._item_url(config, path)}/content" + ) def _request( self, @@ -172,7 +183,10 @@ class OneDriveFilesSource( with open(native_path, "rb") as handle: response = requests.put( upload_url, - headers={"Authorization": f"Bearer {context.config.access_token}", "Content-Type": "application/octet-stream"}, + headers={ + "Authorization": f"Bearer {context.config.access_token}", + "Content-Type": "application/octet-stream", + }, data=handle, timeout=300, ) diff --git a/lib/galaxy/files/templates/examples/production_onedrive.yml b/lib/galaxy/files/templates/examples/production_onedrive.yml index 24d58fde67c..61001efc613 100644 --- a/lib/galaxy/files/templates/examples/production_onedrive.yml +++ b/lib/galaxy/files/templates/examples/production_onedrive.yml @@ -5,6 +5,7 @@ type: onedrive oauth2_client_id: "{{ environment.oauth2_client_id }}" oauth2_client_secret: "{{ environment.oauth2_client_secret }}" + drive_mode: appfolder writable: true environment: oauth2_client_id: diff --git a/lib/galaxy/files/templates/examples/production_onedrive_full.yml b/lib/galaxy/files/templates/examples/production_onedrive_full.yml new file mode 100644 index 00000000000..b00d7838f43 --- /dev/null +++ b/lib/galaxy/files/templates/examples/production_onedrive_full.yml @@ -0,0 +1,17 @@ +- id: onedrive + name: OneDrive + description: Connect to your Microsoft OneDrive app folder to download and upload files. + configuration: + type: onedrive + oauth2_client_id: "{{ environment.oauth2_client_id }}" + oauth2_client_secret: "{{ environment.oauth2_client_secret }}" + oauth2_scope: "offline_access Files.ReadWrite" + drive_mode: full + writable: true + environment: + oauth2_client_id: + type: variable + variable: GALAXY_ONEDRIVE_CLIENT_ID + oauth2_client_secret: + type: variable + variable: GALAXY_ONEDRIVE_CLIENT_SECRET diff --git a/lib/galaxy/files/templates/models.py b/lib/galaxy/files/templates/models.py index cae6152c95c..329c50aecc5 100644 --- a/lib/galaxy/files/templates/models.py +++ b/lib/galaxy/files/templates/models.py @@ -121,6 +121,7 @@ class OneDriveFileSourceTemplateConfiguration(OAuth2TemplateConfiguration, Stric oauth2_client_secret: Union[str, TemplateExpansion] # Microsoft Graph app-folder scope keeps access limited to Apps/. oauth2_scope: Optional[Union[str, TemplateExpansion]] = None + drive_mode: Union[Literal["appfolder", "full"], TemplateExpansion] = "appfolder" template_start: Optional[str] = None template_end: Optional[str] = None @@ -129,6 +130,7 @@ class OneDriveFileSourceConfiguration(OAuth2FileSourceConfiguration, StrictModel type: Literal["onedrive"] writable: bool = False oauth2_access_token: str + drive_mode: Literal["appfolder", "full"] = "appfolder" class S3FSFileSourceTemplateConfiguration(StrictModel): diff --git a/test/unit/app/managers/test_user_file_sources.py b/test/unit/app/managers/test_user_file_sources.py index 83e172f5c82..fcd2cd9e3c9 100644 --- a/test/unit/app/managers/test_user_file_sources.py +++ b/test/unit/app/managers/test_user_file_sources.py @@ -23,10 +23,7 @@ from galaxy.exceptions import ( RequestParameterMissingException, ) from galaxy.files import FileSourcesUserContext -from galaxy.files.sources import ( - dropbox, - onedrive, -) +from galaxy.files.sources import dropbox from galaxy.files.templates import ConfiguredFileSourceTemplates from galaxy.files.templates.examples import get_example from galaxy.managers._config_templates import prepare_environment_from_root @@ -403,7 +400,7 @@ class TestFileSourcesTestCase(BaseTestCase): json = { "access_token": "my_test_access_token", } - observed_headers = {} + observed_headers: dict[str, str] = {} def mock_get_token_from_refresh_raw(refresh_token, client_pair, config): return MockResponse(json) @@ -413,7 +410,7 @@ class TestFileSourcesTestCase(BaseTestCase): return OneDriveMockResponse(json_data={"value": []}) monkeypatch.setattr(config_templates, "get_token_from_refresh_raw", mock_get_token_from_refresh_raw) - monkeypatch.setattr(onedrive.requests, "request", mock_request) + monkeypatch.setattr("galaxy.files.sources.onedrive.requests.request", mock_request) status = self.manager.plugin_status(self.trans, create_payload) assert status.oauth2_access_token_generation assert not status.oauth2_access_token_generation.is_not_ok diff --git a/test/unit/files/test_onedrive.py b/test/unit/files/test_onedrive.py index 9aad56196f6..9e25f38585d 100644 --- a/test/unit/files/test_onedrive.py +++ b/test/unit/files/test_onedrive.py @@ -44,6 +44,20 @@ def _plugin(): return OneDriveFilesSource(template) +def _plugin_full(): + template = OneDriveFilesSource.build_template_config( + id="test1", + type="onedrive", + label="OneDrive", + doc="Test OneDrive file source", + writable=True, + access_token="test_access_token", + drive_mode="full", + file_sources_config=FileSourcePluginsConfig(), + ) + return OneDriveFilesSource(template) + + def test_list_root(monkeypatch): plugin = _plugin() observed = {} @@ -75,6 +89,23 @@ def test_list_root(monkeypatch): assert entries[1].uri == "gxfiles://test1/a.txt" +def test_list_root_full_drive_mode(monkeypatch): + plugin = _plugin_full() + observed = {} + + def mock_request(method, url, headers=None, timeout=None, **kwargs): + observed["url"] = url + return MockResponse(json_data={"value": []}) + + monkeypatch.setattr("galaxy.files.sources.onedrive.requests.request", mock_request) + + entries, count = plugin.list("/") + + assert count == 0 + assert entries == [] + assert observed["url"] == "https://graph.microsoft.com/v1.0/me/drive/root/children" + + def test_list_nested_folder(monkeypatch): plugin = _plugin() observed = {} @@ -92,6 +123,23 @@ def test_list_nested_folder(monkeypatch): assert entries[0].path == "/level1/level 2/b.txt" +def test_list_nested_folder_full_drive_mode(monkeypatch): + plugin = _plugin_full() + observed = {} + + def mock_request(method, url, headers=None, timeout=None, **kwargs): + observed["url"] = url + return MockResponse(json_data={"value": [{"name": "b.txt", "size": 4}]}) + + monkeypatch.setattr("galaxy.files.sources.onedrive.requests.request", mock_request) + + entries, count = plugin.list("/level1/level 2") + + assert count == 1 + assert observed["url"] == "https://graph.microsoft.com/v1.0/me/drive/root:/level1/level%202:/children" + assert entries[0].path == "/level1/level 2/b.txt" + + def test_realize_to_downloads_content(monkeypatch, tmp_path: Path): plugin = _plugin() target = tmp_path / "downloaded.txt" From def489704352d8df8379a69bec1a0b3b62a54959 Mon Sep 17 00:00:00 2001 From: PlushZ Date: Tue, 7 Apr 2026 19:53:18 +1000 Subject: [PATCH 5/7] remove mocking and add integration test instead, extend docs --- doc/source/admin/data.md | 61 ++++---- test/unit/files/_util.py | 1 + test/unit/files/test_onedrive.py | 231 ++----------------------------- 3 files changed, 50 insertions(+), 243 deletions(-) diff --git a/doc/source/admin/data.md b/doc/source/admin/data.md index c118ac78853..14fe3b64a1a 100644 --- a/doc/source/admin/data.md +++ b/doc/source/admin/data.md @@ -629,14 +629,19 @@ app - checkout the [Dropbox documentation](https://www.dropbox.com/developers/re #### OneDrive Once you have OAuth 2.0 client credentials from Microsoft Entra (called `oauth2_client_id` -and `oauth2_client_secret` here), the following configuration can be used to enable +and `oauth2_client_secret` here), the following configurations can be used to enable OneDrive for your Galaxy instance. ```{literalinclude} ../../../lib/galaxy/files/templates/examples/production_onedrive.yml :language: yaml ``` +or -To use this template, make the credentials available to Galaxy's web and job handler +```{literalinclude} ../../../lib/galaxy/files/templates/examples/production_onedrive_full.yml +:language: yaml +``` + +To use one of these templates, make the credentials available to Galaxy's web and job handler processes using the environment variables `GALAXY_ONEDRIVE_CLIENT_ID` and `GALAXY_ONEDRIVE_CLIENT_SECRET`. Jobs themselves do not need these values and should not receive them. @@ -653,23 +658,37 @@ The current OneDrive implementation supports two drive modes: application folder. This mode requires broader delegated Microsoft Graph permissions such as `Files.ReadWrite`. -To configure Microsoft Entra for this file source: +To configure Microsoft Entra app for this file source: -1. Create an app registration for your Galaxy instance in Microsoft Entra. -2. Add a web redirect URI pointing to your Galaxy instance with `oauth2_callback` - appended to the root URL. For local development, this is typically - `http://localhost:8080/oauth2_callback`. -3. Configure supported account types for the accounts you intend to support. If you - want to support both work/school accounts and personal Microsoft accounts, set - the audience to "Any Entra ID tenant + Personal Microsoft accounts". -4. Add delegated Microsoft Graph permissions: - For the default app-folder setup, add permission `Files.ReadWrite.AppFolder`. - To ensure Galaxy can obtain refresh tokens for long-lived access add permission - `offline_access`. -6. Create a client secret and provide its value to Galaxy via - `GALAXY_ONEDRIVE_CLIENT_SECRET` (remember, this value can be seen only once after creation). -7. Go to Overview, find "Application (client) ID" and provide its value to Galaxy - via `GALAXY_ONEDRIVE_CLIENT_ID` +1. Sign in to [Microsoft Azure](https://portal.azure.com/). Go to `Microsoft Entra ID` and open + `App Registrations`. +2. Select `New registration`. +3. Enter a recognizable application name for Galaxy, for example `Galaxy OneDrive`. +4. Under `Supported account types`, choose the audience that matches your deployment. + If Galaxy users may connect both organizational Microsoft accounts and personal + Microsoft accounts, select `Any Entra ID tenant + Personal Microsoft accounts`. +5. Under `Redirect URI`, choose platform type `Web` and enter your Galaxy callback URL: + `/oauth2_callback`. + For example, if Galaxy is available at `https://usegalaxy.eu`, use + `https://usegalaxy.eu/oauth2_callback`. + For local development this is often `http://localhost:8080/oauth2_callback`. +6. Create the registration and open the app's `Overview` page. + Copy the `Application (client) ID` and expose it to Galaxy as + `GALAXY_ONEDRIVE_CLIENT_ID`. +7. Open `Certificates & secrets > Client secrets`, create a new client secret, + and copy the generated secret value immediately. + Expose that value to Galaxy as `GALAXY_ONEDRIVE_CLIENT_SECRET`. + Microsoft only shows the full secret value once. +8. Open `API permissions` and add Microsoft Graph delegated permissions. + For the default app-folder configuration, add `Files.ReadWrite.AppFolder`. + Also add `offline_access` so Galaxy can obtain refresh tokens for long-lived access. +9. If your deployment uses `drive_mode: full` instead of the default `appfolder`, + add delegated permission `Files.ReadWrite` instead of `Files.ReadWrite.AppFolder`. + This must match the scope requested in the Galaxy template. + +After this setup, users connect their own OneDrive accounts through Galaxy's OAuth2 +flow. The client ID and client secret identify your Galaxy application to Microsoft, +but file access is performed with per-user delegated access and refresh tokens. To configure full-drive access instead of the default app-folder mode, you need to change both the Galaxy yml config template and the Microsoft Entra app registration. @@ -680,12 +699,6 @@ matching delegated Microsoft Graph permission (`Files.ReadWrite` instead of `drive_mode` remains `appfolder`, Galaxy will continue to operate only inside the application folder. -The OAuth2 endpoints Galaxy uses for this template are the Microsoft identity platform's -v2 endpoints under the `common` tenant. If you register an application that also supports -personal Microsoft accounts, ensure the manifest is consistent with that audience. In -particular, the app manifest should use `AzureADandPersonalMicrosoftAccount` as the -`signInAudience` and `2` as the `requestedAccessTokenVersion`. - This implementation currently uses Microsoft Graph's simple upload endpoint and does not yet implement resumable uploads for very large files, server-side pagination, or server-side search/sorting. diff --git a/test/unit/files/_util.py b/test/unit/files/_util.py index 5fbb9e3ff4c..e17501e72e7 100644 --- a/test/unit/files/_util.py +++ b/test/unit/files/_util.py @@ -75,6 +75,7 @@ def user_context_fixture(user_ftp_dir=None, role_names=None, group_names=None, i "googledrive|client_secret": os.environ.get("GALAXY_TEST_GOOGLE_DRIVE_CLIENT_SECRET"), "googledrive|access_token": os.environ.get("GALAXY_TEST_GOOGLE_DRIVE_ACCESS_TOKEN"), "googledrive|refresh_token": os.environ.get("GALAXY_TEST_GOOGLE_DRIVE_REFRESH_TOKEN"), + "onedrive|access_token": os.environ.get("GALAXY_TEST_ONEDRIVE_ACCESS_TOKEN"), "googlecloudstorage|project": os.environ.get("GALAXY_TEST_GCS_PROJECT"), "googlecloudstorage|bucket_name": os.environ.get("GALAXY_TEST_GCS_BUCKET"), "googlecloudstorage|client_id": os.environ.get("GALAXY_TEST_GCS_CLIENT_ID"), diff --git a/test/unit/files/test_onedrive.py b/test/unit/files/test_onedrive.py index 9e25f38585d..5a9e8765522 100644 --- a/test/unit/files/test_onedrive.py +++ b/test/unit/files/test_onedrive.py @@ -1,225 +1,18 @@ -from pathlib import Path +import os import pytest -from galaxy.exceptions import ( - AuthenticationRequired, - MessageException, +from ._util import assert_simple_file_realize + +SCRIPT_DIRECTORY = os.path.abspath(os.path.dirname(__file__)) +FILE_SOURCES_CONF = os.path.join(SCRIPT_DIRECTORY, "onedrive_file_sources_conf.yml") + +skip_if_no_onedrive_access_token = pytest.mark.skipif( + not os.environ.get("GALAXY_TEST_ONEDRIVE_ACCESS_TOKEN"), + reason="GALAXY_TEST_ONEDRIVE_ACCESS_TOKEN not set", ) -from galaxy.files.models import ( - EntryData, - FileSourcePluginsConfig, -) -from galaxy.files.sources.onedrive import OneDriveFilesSource -class MockResponse: - def __init__(self, status_code=200, json_data=None, text="", content_chunks=None): - self.status_code = status_code - self._json_data = json_data or {} - self.text = text - self._content_chunks = content_chunks or [] - - @property - def ok(self): - return 200 <= self.status_code < 300 - - def json(self): - return self._json_data - - def iter_content(self, chunk_size=1024 * 1024): - yield from self._content_chunks - - -def _plugin(): - template = OneDriveFilesSource.build_template_config( - id="test1", - type="onedrive", - label="OneDrive", - doc="Test OneDrive file source", - writable=True, - access_token="test_access_token", - file_sources_config=FileSourcePluginsConfig(), - ) - return OneDriveFilesSource(template) - - -def _plugin_full(): - template = OneDriveFilesSource.build_template_config( - id="test1", - type="onedrive", - label="OneDrive", - doc="Test OneDrive file source", - writable=True, - access_token="test_access_token", - drive_mode="full", - file_sources_config=FileSourcePluginsConfig(), - ) - return OneDriveFilesSource(template) - - -def test_list_root(monkeypatch): - plugin = _plugin() - observed = {} - - def mock_request(method, url, headers=None, timeout=None, **kwargs): - observed["method"] = method - observed["url"] = url - observed["headers"] = headers - return MockResponse( - json_data={ - "value": [ - {"name": "subdir", "folder": {}, "size": 0}, - {"name": "a.txt", "size": 12, "lastModifiedDateTime": "2026-04-05T12:00:00Z"}, - ] - } - ) - - monkeypatch.setattr("galaxy.files.sources.onedrive.requests.request", mock_request) - - entries, count = plugin.list("/") - - assert count == 2 - assert observed["method"] == "GET" - assert observed["url"] == "https://graph.microsoft.com/v1.0/me/drive/special/approot/children" - assert observed["headers"]["Authorization"] == "Bearer test_access_token" - assert entries[0].path == "/subdir" - assert entries[0].uri == "gxfiles://test1/subdir" - assert entries[1].path == "/a.txt" - assert entries[1].uri == "gxfiles://test1/a.txt" - - -def test_list_root_full_drive_mode(monkeypatch): - plugin = _plugin_full() - observed = {} - - def mock_request(method, url, headers=None, timeout=None, **kwargs): - observed["url"] = url - return MockResponse(json_data={"value": []}) - - monkeypatch.setattr("galaxy.files.sources.onedrive.requests.request", mock_request) - - entries, count = plugin.list("/") - - assert count == 0 - assert entries == [] - assert observed["url"] == "https://graph.microsoft.com/v1.0/me/drive/root/children" - - -def test_list_nested_folder(monkeypatch): - plugin = _plugin() - observed = {} - - def mock_request(method, url, headers=None, timeout=None, **kwargs): - observed["url"] = url - return MockResponse(json_data={"value": [{"name": "b.txt", "size": 4}]}) - - monkeypatch.setattr("galaxy.files.sources.onedrive.requests.request", mock_request) - - entries, count = plugin.list("/level1/level 2") - - assert count == 1 - assert observed["url"] == "https://graph.microsoft.com/v1.0/me/drive/special/approot:/level1/level%202:/children" - assert entries[0].path == "/level1/level 2/b.txt" - - -def test_list_nested_folder_full_drive_mode(monkeypatch): - plugin = _plugin_full() - observed = {} - - def mock_request(method, url, headers=None, timeout=None, **kwargs): - observed["url"] = url - return MockResponse(json_data={"value": [{"name": "b.txt", "size": 4}]}) - - monkeypatch.setattr("galaxy.files.sources.onedrive.requests.request", mock_request) - - entries, count = plugin.list("/level1/level 2") - - assert count == 1 - assert observed["url"] == "https://graph.microsoft.com/v1.0/me/drive/root:/level1/level%202:/children" - assert entries[0].path == "/level1/level 2/b.txt" - - -def test_realize_to_downloads_content(monkeypatch, tmp_path: Path): - plugin = _plugin() - target = tmp_path / "downloaded.txt" - - def mock_request(method, url, headers=None, timeout=None, stream=None, **kwargs): - assert method == "GET" - assert stream is True - assert url == "https://graph.microsoft.com/v1.0/me/drive/special/approot:/dir/file.txt:/content" - return MockResponse(content_chunks=[b"hello ", b"world"]) - - monkeypatch.setattr("galaxy.files.sources.onedrive.requests.request", mock_request) - - plugin.realize_to("/dir/file.txt", str(target)) - - assert target.read_text("utf-8") == "hello world" - - -def test_write_from_uploads_content(monkeypatch, tmp_path: Path): - plugin = _plugin() - source = tmp_path / "upload.txt" - source.write_text("payload", "utf-8") - observed = {} - - def mock_put(url, headers=None, data=None, timeout=None): - observed["url"] = url - observed["headers"] = headers - observed["data"] = data.read() - return MockResponse(json_data={"id": "item1"}) - - monkeypatch.setattr("galaxy.files.sources.onedrive.requests.put", mock_put) - - actual_uri = plugin.write_from("/nested/upload.txt", str(source)) - - assert actual_uri == "gxfiles://test1/nested/upload.txt" - assert observed["url"] == "https://graph.microsoft.com/v1.0/me/drive/special/approot:/nested/upload.txt:/content" - assert observed["headers"]["Authorization"] == "Bearer test_access_token" - assert observed["data"] == b"payload" - - -def test_create_entry_creates_directory(monkeypatch): - plugin = _plugin() - observed = {} - - def mock_post(url, json=None, headers=None, timeout=None): - observed["url"] = url - observed["json"] = json - observed["headers"] = headers - return MockResponse(json_data={"name": "newdir", "webUrl": "https://example.org/newdir"}) - - monkeypatch.setattr("galaxy.files.sources.onedrive.requests.post", mock_post) - - entry = plugin.create_entry(EntryData(name="newdir", target="gxfiles://test1/parent")) - - assert observed["url"] == "https://graph.microsoft.com/v1.0/me/drive/special/approot:/parent:/children" - assert observed["json"]["name"] == "newdir" - assert entry.uri == "gxfiles://test1/parent/newdir" - assert entry.external_link == "https://example.org/newdir" - - -def test_list_authentication_error(monkeypatch): - plugin = _plugin() - - def mock_request(method, url, headers=None, timeout=None, **kwargs): - return MockResponse(status_code=401, json_data={"error": {"message": "Unauthorized"}}) - - monkeypatch.setattr("galaxy.files.sources.onedrive.requests.request", mock_request) - - with pytest.raises(AuthenticationRequired): - plugin.list("/") - - -def test_write_reports_api_error(monkeypatch, tmp_path: Path): - plugin = _plugin() - source = tmp_path / "upload.txt" - source.write_text("payload", "utf-8") - - def mock_put(url, headers=None, data=None, timeout=None): - return MockResponse(status_code=400, json_data={"error": {"message": "Bad request"}}) - - monkeypatch.setattr("galaxy.files.sources.onedrive.requests.put", mock_put) - - with pytest.raises(MessageException, match="Bad request"): - plugin.write_from("/upload.txt", str(source)) +@skip_if_no_onedrive_access_token +def test_file_source(): + assert_simple_file_realize(FILE_SOURCES_CONF) From c30d30f3866b41fee07f7ef754b4b8ce929b3f43 Mon Sep 17 00:00:00 2001 From: PlushZ Date: Thu, 9 Apr 2026 17:20:51 +1000 Subject: [PATCH 6/7] fixes after review --- client/src/api/fileSources.ts | 2 +- doc/source/admin/data.md | 5 +++ lib/galaxy/files/sources/onedrive.py | 44 ++++--------------- .../onedrive_client_secrets_in_vault.yml | 16 +++++++ .../examples/production_onedrive.yml | 4 +- .../examples/production_onedrive_full.yml | 6 ++- .../unit/files/onedrive_file_sources_conf.yml | 4 ++ 7 files changed, 40 insertions(+), 41 deletions(-) create mode 100644 lib/galaxy/files/templates/examples/onedrive_client_secrets_in_vault.yml create mode 100644 test/unit/files/onedrive_file_sources_conf.yml diff --git a/client/src/api/fileSources.ts b/client/src/api/fileSources.ts index c420b9ed3d6..fa83155d29d 100644 --- a/client/src/api/fileSources.ts +++ b/client/src/api/fileSources.ts @@ -34,7 +34,7 @@ export const templateTypes: FileSourceTypesDetail = { }, onedrive: { icon: faCloud, - message: "This is a repository plugin that connects with Microsoft OneDrive through Microsoft Graph.", + message: "This is a repository plugin that connects with Microsoft OneDrive.", }, onedata: { icon: faNetworkWired, diff --git a/doc/source/admin/data.md b/doc/source/admin/data.md index 14fe3b64a1a..d4f22558f4c 100644 --- a/doc/source/admin/data.md +++ b/doc/source/admin/data.md @@ -645,6 +645,11 @@ To use one of these templates, make the credentials available to Galaxy's web an processes using the environment variables `GALAXY_ONEDRIVE_CLIENT_ID` and `GALAXY_ONEDRIVE_CLIENT_SECRET`. Jobs themselves do not need these values and should not receive them. +If your Galaxy instance has Vault configured, you can use this Vault-backed variant instead: + +```{literalinclude} ../../../lib/galaxy/files/templates/examples/onedrive_client_secrets_in_vault.yml +:language: yaml +``` The current OneDrive implementation supports two drive modes: diff --git a/lib/galaxy/files/sources/onedrive.py b/lib/galaxy/files/sources/onedrive.py index 70a97cd8ad5..93638458fcc 100644 --- a/lib/galaxy/files/sources/onedrive.py +++ b/lib/galaxy/files/sources/onedrive.py @@ -65,11 +65,6 @@ class OneDriveFilesSource(BaseFilesSource[OneDriveFileSourceTemplateConfiguratio "Authorization": f"Bearer {config.access_token}", } - def _json_headers(self, config: OneDriveFilesSourceConfiguration) -> dict[str, str]: - headers = self._headers(config) - headers["Content-Type"] = "application/json" - return headers - def _encoded_path(self, path: str) -> str: normalized = path.strip("/") if not normalized: @@ -105,10 +100,11 @@ class OneDriveFilesSource(BaseFilesSource[OneDriveFileSourceTemplateConfiguratio method: str, url: str, context: FilesSourceRuntimeContext[OneDriveFilesSourceConfiguration], + timeout: int = 30, **kwargs, ) -> requests.Response: try: - response = requests.request(method, url, headers=self._headers(context.config), timeout=30, **kwargs) + response = requests.request(method, url, headers=self._headers(context.config), timeout=timeout, **kwargs) except requests.RequestException as exc: raise MessageException(f"Error connecting to OneDrive. Reason: {exc}") from exc @@ -181,26 +177,13 @@ class OneDriveFilesSource(BaseFilesSource[OneDriveFileSourceTemplateConfiguratio ) -> str: upload_url = self._content_url(context.config, target_path) with open(native_path, "rb") as handle: - response = requests.put( + self._request( + "PUT", upload_url, - headers={ - "Authorization": f"Bearer {context.config.access_token}", - "Content-Type": "application/octet-stream", - }, + context, data=handle, timeout=300, ) - if response.status_code in {401, 403}: - raise AuthenticationRequired( - "Permission denied while writing to OneDrive. Check the Microsoft app scopes and stored user authorization." - ) - if not response.ok: - try: - payload = response.json() - message = payload.get("error", {}).get("message", response.text) - except Exception: - message = response.text - raise MessageException(f"Error uploading to OneDrive. Reason: {message}") return self.uri_from_path(target_path) def _create_entry( @@ -215,23 +198,12 @@ class OneDriveFilesSource(BaseFilesSource[OneDriveFileSourceTemplateConfiguratio "folder": {}, "@microsoft.graph.conflictBehavior": "fail", } - response = requests.post( + response = self._request( + "POST", self._children_url(context.config, parent_path), + context, json=payload, - headers=self._json_headers(context.config), - timeout=30, ) - if response.status_code in {401, 403}: - raise AuthenticationRequired( - "Permission denied while creating a OneDrive folder. Check the Microsoft app scopes and stored user authorization." - ) - if not response.ok: - try: - body = response.json() - message = body.get("error", {}).get("message", response.text) - except Exception: - message = response.text - raise MessageException(f"Error creating OneDrive folder. Reason: {message}") item = response.json() path = self._entry_from_item(item, parent_path).path return Entry(name=item["name"], uri=self.uri_from_path(path), external_link=item.get("webUrl")) diff --git a/lib/galaxy/files/templates/examples/onedrive_client_secrets_in_vault.yml b/lib/galaxy/files/templates/examples/onedrive_client_secrets_in_vault.yml new file mode 100644 index 00000000000..9d7ba869558 --- /dev/null +++ b/lib/galaxy/files/templates/examples/onedrive_client_secrets_in_vault.yml @@ -0,0 +1,16 @@ +- id: onedrive + name: OneDrive + description: Connect to your Microsoft OneDrive app folder to download and upload files. + configuration: + type: onedrive + oauth2_client_id: "{{ environment.oauth2_client_id }}" + oauth2_client_secret: "{{ environment.oauth2_client_secret }}" + drive_mode: appfolder + writable: true + environment: + oauth2_client_id: + type: secret + vault_key: "onedrive_file_source/client_id" + oauth2_client_secret: + type: secret + vault_key: "onedrive_file_source/client_secret" diff --git a/lib/galaxy/files/templates/examples/production_onedrive.yml b/lib/galaxy/files/templates/examples/production_onedrive.yml index 61001efc613..647239363e7 100644 --- a/lib/galaxy/files/templates/examples/production_onedrive.yml +++ b/lib/galaxy/files/templates/examples/production_onedrive.yml @@ -1,6 +1,6 @@ -- id: onedrive +- id: onedrive_appfolder name: OneDrive - description: Connect to your Microsoft OneDrive app folder to download and upload files. + description: Connect to the Galaxy folder in your Microsoft OneDrive. Galaxy will only access its own app folder, not the rest of your OneDrive. configuration: type: onedrive oauth2_client_id: "{{ environment.oauth2_client_id }}" diff --git a/lib/galaxy/files/templates/examples/production_onedrive_full.yml b/lib/galaxy/files/templates/examples/production_onedrive_full.yml index b00d7838f43..df675c9ba69 100644 --- a/lib/galaxy/files/templates/examples/production_onedrive_full.yml +++ b/lib/galaxy/files/templates/examples/production_onedrive_full.yml @@ -1,6 +1,8 @@ -- id: onedrive +- id: onedrive_full name: OneDrive - description: Connect to your Microsoft OneDrive app folder to download and upload files. + description: | + Connect to your full Microsoft OneDrive. + You will be asked to grant Galaxy permission to access files across your OneDrive, not just the Galaxy app folder. configuration: type: onedrive oauth2_client_id: "{{ environment.oauth2_client_id }}" diff --git a/test/unit/files/onedrive_file_sources_conf.yml b/test/unit/files/onedrive_file_sources_conf.yml new file mode 100644 index 00000000000..0f76d776c6f --- /dev/null +++ b/test/unit/files/onedrive_file_sources_conf.yml @@ -0,0 +1,4 @@ +- type: onedrive + id: test1 + doc: Test access to a OneDrive account. + accessToken: ${user.preferences['onedrive|access_token']} From 8d0cee97e0b33d0e89c3a17710de4f03cc70e7e5 Mon Sep 17 00:00:00 2001 From: PlushZ Date: Thu, 9 Apr 2026 17:57:13 +1000 Subject: [PATCH 7/7] fix filesource id --- lib/galaxy/files/templates/examples/production_onedrive.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/files/templates/examples/production_onedrive.yml b/lib/galaxy/files/templates/examples/production_onedrive.yml index 647239363e7..9878373b92c 100644 --- a/lib/galaxy/files/templates/examples/production_onedrive.yml +++ b/lib/galaxy/files/templates/examples/production_onedrive.yml @@ -1,4 +1,4 @@ -- id: onedrive_appfolder +- id: onedrive name: OneDrive description: Connect to the Galaxy folder in your Microsoft OneDrive. Galaxy will only access its own app folder, not the rest of your OneDrive. configuration: