From c54f5f178d6e24fb6a24646f3f044e279f691d65 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Thu, 17 Sep 2026 16:59:34 -0700 Subject: [PATCH 1/8] Fix upload/download/list attachment 401s and job update 404 - upload_attachment, download_attachment, and list_attachments in base_job.py were reusing the unsigned container_uri returned by job creation instead of always fetching a fresh SAS-signed URI via workspace.get_container_uri(), causing 401 NoAuthenticationInformation errors. - build_services_jobs_update_request in the generated _operations.py used the wrong URL path segment 'jobUpdateOptions' (the request model type name) instead of 'jobs', causing workspace.update_job() to fail with 404 Not Found. --- .../quantum/_client/operations/_operations.py | 2 +- azure-quantum/azure/quantum/job/base_job.py | 26 +++++++------------ 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/azure-quantum/azure/quantum/_client/operations/_operations.py b/azure-quantum/azure/quantum/_client/operations/_operations.py index 4f6b49cc..ab94da32 100644 --- a/azure-quantum/azure/quantum/_client/operations/_operations.py +++ b/azure-quantum/azure/quantum/_client/operations/_operations.py @@ -130,7 +130,7 @@ def build_services_jobs_update_request( accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Quantum/workspaces/{workspaceName}/jobUpdateOptions/{jobId}" + _url = "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Quantum/workspaces/{workspaceName}/jobs/{jobId}" path_format_arguments = { "subscriptionId": _SERIALIZER.url("subscription_id", subscription_id, "str"), "resourceGroupName": _SERIALIZER.url("resource_group_name", resource_group_name, "str"), diff --git a/azure-quantum/azure/quantum/job/base_job.py b/azure-quantum/azure/quantum/job/base_job.py index 9ffbd9ff..b7630611 100644 --- a/azure-quantum/azure/quantum/job/base_job.py +++ b/azure-quantum/azure/quantum/job/base_job.py @@ -344,12 +344,10 @@ def upload_attachment( :rtype: str """ - # Use Job's default container if not specified + # Use Job's default container if not specified. self._details.container_uri is + # unsigned, so always fetch a fresh SAS-signed URI instead of reusing it. if container_uri is None: - if self._details.container_uri is None: - container_uri = self.workspace.get_container_uri(job_id=self.id) - else: - container_uri = self._details.container_uri + container_uri = self.workspace.get_container_uri(job_id=self.id) uploaded_blob_uri = self.upload_input_data( container_uri = container_uri, @@ -377,13 +375,11 @@ def download_attachment( :rtype: bytes """ - # Use Job's default container if not specified + # Use Job's default container if not specified. self._details.container_uri is + # unsigned, so always fetch a fresh SAS-signed URI instead of reusing it. if container_uri is None: - if self._details.container_uri is None: - container_uri = self.workspace.get_container_uri(job_id=self.id) - else: - container_uri = self._details.container_uri - + container_uri = self.workspace.get_container_uri(job_id=self.id) + container_client = ContainerClient.from_container_url(container_uri) blob_client = container_client.get_blob_client(name) response = blob_client.download_blob().readall() @@ -399,11 +395,9 @@ def list_attachments(self) -> list[BlobProperties]: :rtype: list[~azure.storage.blob.BlobProperties] """ - # Use the job's linked storage container. - if self._details.container_uri is None: - container_uri = self.workspace.get_container_uri(job_id=self.id) - else: - container_uri = self._details.container_uri + # Use the job's linked storage container. self._details.container_uri is unsigned, + # so always fetch a fresh SAS-signed URI instead of reusing it. + container_uri = self.workspace.get_container_uri(job_id=self.id) container_client = ContainerClient.from_container_url(container_uri) return list(container_client.list_blobs()) From 9a57ae56a6220384eca3d981b99f099d715d28a5 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Fri, 18 Sep 2026 10:57:13 -0700 Subject: [PATCH 2/8] Update tests for fresh attachment SAS URIs --- azure-quantum/tests/test_job_attachments.py | 69 +++++++++++++++++++-- azure-quantum/tests/test_workspace.py | 17 +++++ 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/azure-quantum/tests/test_job_attachments.py b/azure-quantum/tests/test_job_attachments.py index d537f61b..78a87093 100644 --- a/azure-quantum/tests/test_job_attachments.py +++ b/azure-quantum/tests/test_job_attachments.py @@ -7,10 +7,11 @@ from azure.quantum import Job, JobDetails -CONTAINER_URI = "https://acct.blob.core.windows.net/job-id?sas" +UNSIGNED_CONTAINER_URI = "https://acct.blob.core.windows.net/job-id" +SIGNED_CONTAINER_URI = f"{UNSIGNED_CONTAINER_URI}?sas" -def _job_with_container(container_uri=CONTAINER_URI, workspace=None) -> Job: +def _job_with_container(container_uri=UNSIGNED_CONTAINER_URI, workspace=None) -> Job: job_details = JobDetails( id="job-id", name="", @@ -25,7 +26,9 @@ def _job_with_container(container_uri=CONTAINER_URI, workspace=None) -> Job: @patch("azure.quantum.job.base_job.ContainerClient") def test_list_attachments_returns_container_blobs(mock_container_client): - job = _job_with_container() + workspace = Mock() + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI + job = _job_with_container(workspace=workspace) blob_a = Mock() blob_b = Mock() @@ -34,14 +37,15 @@ def test_list_attachments_returns_container_blobs(mock_container_client): result = job.list_attachments() - mock_container_client.from_container_url.assert_called_once_with(CONTAINER_URI) + workspace.get_container_uri.assert_called_once_with(job_id="job-id") + mock_container_client.from_container_url.assert_called_once_with(SIGNED_CONTAINER_URI) assert result == [blob_a, blob_b] @patch("azure.quantum.job.base_job.ContainerClient") def test_list_attachments_uses_workspace_container_when_unset(mock_container_client): workspace = Mock() - workspace.get_container_uri.return_value = CONTAINER_URI + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI job = _job_with_container(container_uri=None, workspace=workspace) container = mock_container_client.from_container_url.return_value @@ -50,5 +54,58 @@ def test_list_attachments_uses_workspace_container_when_unset(mock_container_cli result = job.list_attachments() workspace.get_container_uri.assert_called_once_with(job_id="job-id") - mock_container_client.from_container_url.assert_called_once_with(CONTAINER_URI) + mock_container_client.from_container_url.assert_called_once_with(SIGNED_CONTAINER_URI) assert result == [] + + +def test_upload_attachment_uses_fresh_workspace_container_uri(): + workspace = Mock() + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI + job = _job_with_container(workspace=workspace) + job.upload_input_data = Mock(return_value="uploaded-uri") + + result = job.upload_attachment("attachment", b"data") + + workspace.get_container_uri.assert_called_once_with(job_id="job-id") + job.upload_input_data.assert_called_once_with( + container_uri=SIGNED_CONTAINER_URI, + blob_name="attachment", + input_data=b"data", + ) + assert result == "uploaded-uri" + + +@patch("azure.quantum.job.base_job.ContainerClient") +def test_download_attachment_uses_fresh_workspace_container_uri(mock_container_client): + workspace = Mock() + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI + job = _job_with_container(workspace=workspace) + blob_client = mock_container_client.from_container_url.return_value.get_blob_client.return_value + blob_client.download_blob.return_value.readall.return_value = b"data" + + result = job.download_attachment("attachment") + + workspace.get_container_uri.assert_called_once_with(job_id="job-id") + mock_container_client.from_container_url.assert_called_once_with(SIGNED_CONTAINER_URI) + assert result == b"data" + + +@patch("azure.quantum.job.base_job.ContainerClient") +def test_attachment_methods_honor_explicit_container_uri(mock_container_client): + workspace = Mock() + job = _job_with_container(workspace=workspace) + job.upload_input_data = Mock(return_value="uploaded-uri") + blob_client = mock_container_client.from_container_url.return_value.get_blob_client.return_value + blob_client.download_blob.return_value.readall.return_value = b"data" + explicit_uri = "https://custom.blob.core.windows.net/container?sas" + + job.upload_attachment("upload", b"data", container_uri=explicit_uri) + job.download_attachment("download", container_uri=explicit_uri) + + workspace.get_container_uri.assert_not_called() + job.upload_input_data.assert_called_once_with( + container_uri=explicit_uri, + blob_name="upload", + input_data=b"data", + ) + mock_container_client.from_container_url.assert_called_once_with(explicit_uri) diff --git a/azure-quantum/tests/test_workspace.py b/azure-quantum/tests/test_workspace.py index 4fc0151f..00aa1f95 100644 --- a/azure-quantum/tests/test_workspace.py +++ b/azure-quantum/tests/test_workspace.py @@ -8,6 +8,7 @@ from unittest import mock from azure.quantum.job.job import Job from azure.quantum._client.models import JobDetails +from azure.quantum._client.operations._operations import build_services_jobs_update_request from azure.quantum import Priority from azure.quantum._constants import EnvironmentVariables, ConnectionConstants from azure.core.credentials import AzureKeyCredential @@ -475,6 +476,22 @@ def test_workspace_update_job_success(): assert result.details.tags == ["tag-a", "tag-b"] +def test_workspace_update_job_request_uses_jobs_resource_path(): + request = build_services_jobs_update_request( + subscription_id=SUBSCRIPTION_ID, + resource_group_name=RESOURCE_GROUP, + workspace_name=WORKSPACE, + job_id="test-update-route", + ) + + assert request.method == "PATCH" + assert request.url.split("?", maxsplit=1)[0] == ( + f"/subscriptions/{SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}" + f"/providers/Microsoft.Quantum/workspaces/{WORKSPACE}" + "/jobs/test-update-route" + ) + + def test_workspace_update_job_partial_leaves_other_fields_unchanged(): ws = WorkspaceMock( subscription_id=SUBSCRIPTION_ID, From 36790fc06b3cd8bcc9ad1642ad2a006fb1fe310c Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Fri, 18 Sep 2026 11:33:22 -0700 Subject: [PATCH 3/8] Preserve custom attachment container names --- azure-quantum/azure/quantum/job/base_job.py | 15 ++++- azure-quantum/tests/test_job_attachments.py | 66 ++++++++++++++++++--- 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/azure-quantum/azure/quantum/job/base_job.py b/azure-quantum/azure/quantum/job/base_job.py index b7630611..f6abaee9 100644 --- a/azure-quantum/azure/quantum/job/base_job.py +++ b/azure-quantum/azure/quantum/job/base_job.py @@ -347,7 +347,10 @@ def upload_attachment( # Use Job's default container if not specified. self._details.container_uri is # unsigned, so always fetch a fresh SAS-signed URI instead of reusing it. if container_uri is None: - container_uri = self.workspace.get_container_uri(job_id=self.id) + container_uri = self.workspace.get_container_uri( + job_id=self.id, + container_name=self.container_name, + ) uploaded_blob_uri = self.upload_input_data( container_uri = container_uri, @@ -378,7 +381,10 @@ def download_attachment( # Use Job's default container if not specified. self._details.container_uri is # unsigned, so always fetch a fresh SAS-signed URI instead of reusing it. if container_uri is None: - container_uri = self.workspace.get_container_uri(job_id=self.id) + container_uri = self.workspace.get_container_uri( + job_id=self.id, + container_name=self.container_name, + ) container_client = ContainerClient.from_container_url(container_uri) blob_client = container_client.get_blob_client(name) @@ -397,7 +403,10 @@ def list_attachments(self) -> list[BlobProperties]: # Use the job's linked storage container. self._details.container_uri is unsigned, # so always fetch a fresh SAS-signed URI instead of reusing it. - container_uri = self.workspace.get_container_uri(job_id=self.id) + container_uri = self.workspace.get_container_uri( + job_id=self.id, + container_name=self.container_name, + ) container_client = ContainerClient.from_container_url(container_uri) return list(container_client.list_blobs()) diff --git a/azure-quantum/tests/test_job_attachments.py b/azure-quantum/tests/test_job_attachments.py index 78a87093..95ddda66 100644 --- a/azure-quantum/tests/test_job_attachments.py +++ b/azure-quantum/tests/test_job_attachments.py @@ -3,17 +3,19 @@ # Licensed under the MIT License. ## -from unittest.mock import Mock, patch +from unittest.mock import Mock, call, patch from azure.quantum import Job, JobDetails -UNSIGNED_CONTAINER_URI = "https://acct.blob.core.windows.net/job-id" +JOB_ID = "job-id" +DEFAULT_CONTAINER_NAME = f"job-{JOB_ID}" +UNSIGNED_CONTAINER_URI = f"https://acct.blob.core.windows.net/{DEFAULT_CONTAINER_NAME}" SIGNED_CONTAINER_URI = f"{UNSIGNED_CONTAINER_URI}?sas" def _job_with_container(container_uri=UNSIGNED_CONTAINER_URI, workspace=None) -> Job: job_details = JobDetails( - id="job-id", + id=JOB_ID, name="", provider_id="", target="", @@ -37,7 +39,10 @@ def test_list_attachments_returns_container_blobs(mock_container_client): result = job.list_attachments() - workspace.get_container_uri.assert_called_once_with(job_id="job-id") + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) mock_container_client.from_container_url.assert_called_once_with(SIGNED_CONTAINER_URI) assert result == [blob_a, blob_b] @@ -53,7 +58,10 @@ def test_list_attachments_uses_workspace_container_when_unset(mock_container_cli result = job.list_attachments() - workspace.get_container_uri.assert_called_once_with(job_id="job-id") + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) mock_container_client.from_container_url.assert_called_once_with(SIGNED_CONTAINER_URI) assert result == [] @@ -66,7 +74,10 @@ def test_upload_attachment_uses_fresh_workspace_container_uri(): result = job.upload_attachment("attachment", b"data") - workspace.get_container_uri.assert_called_once_with(job_id="job-id") + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) job.upload_input_data.assert_called_once_with( container_uri=SIGNED_CONTAINER_URI, blob_name="attachment", @@ -85,7 +96,10 @@ def test_download_attachment_uses_fresh_workspace_container_uri(mock_container_c result = job.download_attachment("attachment") - workspace.get_container_uri.assert_called_once_with(job_id="job-id") + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) mock_container_client.from_container_url.assert_called_once_with(SIGNED_CONTAINER_URI) assert result == b"data" @@ -109,3 +123,41 @@ def test_attachment_methods_honor_explicit_container_uri(mock_container_client): input_data=b"data", ) mock_container_client.from_container_url.assert_called_once_with(explicit_uri) + + +@patch("azure.quantum.job.base_job.ContainerClient") +def test_attachment_methods_preserve_custom_container_name(mock_container_client): + custom_container_name = "custom-container" + custom_unsigned_uri = f"https://acct.blob.core.windows.net/{custom_container_name}" + custom_signed_uri = f"{custom_unsigned_uri}?sas" + workspace = Mock() + workspace.get_container_uri.return_value = custom_signed_uri + job = _job_with_container(container_uri=custom_unsigned_uri, workspace=workspace) + job.upload_input_data = Mock(return_value="uploaded-uri") + container_client = mock_container_client.from_container_url.return_value + container_client.list_blobs.return_value = [] + container_client.get_blob_client.return_value.download_blob.return_value.readall.return_value = b"data" + + job.upload_attachment("upload", b"data") + attachments = job.list_attachments() + downloaded = job.download_attachment("download") + + workspace.get_container_uri.assert_has_calls( + [ + call(job_id=JOB_ID, container_name=custom_container_name), + call(job_id=JOB_ID, container_name=custom_container_name), + call(job_id=JOB_ID, container_name=custom_container_name), + ] + ) + assert workspace.get_container_uri.call_count == 3 + job.upload_input_data.assert_called_once_with( + container_uri=custom_signed_uri, + blob_name="upload", + input_data=b"data", + ) + assert mock_container_client.from_container_url.call_args_list == [ + call(custom_signed_uri), + call(custom_signed_uri), + ] + assert attachments == [] + assert downloaded == b"data" From 06992ac13bcc212d4bc523486134d6a992305c83 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Fri, 18 Sep 2026 11:42:27 -0700 Subject: [PATCH 4/8] Leave generated client updates to PR 772 --- .../quantum/_client/operations/_operations.py | 2 +- azure-quantum/tests/test_workspace.py | 17 ----------------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/azure-quantum/azure/quantum/_client/operations/_operations.py b/azure-quantum/azure/quantum/_client/operations/_operations.py index ab94da32..4f6b49cc 100644 --- a/azure-quantum/azure/quantum/_client/operations/_operations.py +++ b/azure-quantum/azure/quantum/_client/operations/_operations.py @@ -130,7 +130,7 @@ def build_services_jobs_update_request( accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Quantum/workspaces/{workspaceName}/jobs/{jobId}" + _url = "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Quantum/workspaces/{workspaceName}/jobUpdateOptions/{jobId}" path_format_arguments = { "subscriptionId": _SERIALIZER.url("subscription_id", subscription_id, "str"), "resourceGroupName": _SERIALIZER.url("resource_group_name", resource_group_name, "str"), diff --git a/azure-quantum/tests/test_workspace.py b/azure-quantum/tests/test_workspace.py index 00aa1f95..4fc0151f 100644 --- a/azure-quantum/tests/test_workspace.py +++ b/azure-quantum/tests/test_workspace.py @@ -8,7 +8,6 @@ from unittest import mock from azure.quantum.job.job import Job from azure.quantum._client.models import JobDetails -from azure.quantum._client.operations._operations import build_services_jobs_update_request from azure.quantum import Priority from azure.quantum._constants import EnvironmentVariables, ConnectionConstants from azure.core.credentials import AzureKeyCredential @@ -476,22 +475,6 @@ def test_workspace_update_job_success(): assert result.details.tags == ["tag-a", "tag-b"] -def test_workspace_update_job_request_uses_jobs_resource_path(): - request = build_services_jobs_update_request( - subscription_id=SUBSCRIPTION_ID, - resource_group_name=RESOURCE_GROUP, - workspace_name=WORKSPACE, - job_id="test-update-route", - ) - - assert request.method == "PATCH" - assert request.url.split("?", maxsplit=1)[0] == ( - f"/subscriptions/{SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}" - f"/providers/Microsoft.Quantum/workspaces/{WORKSPACE}" - "/jobs/test-update-route" - ) - - def test_workspace_update_job_partial_leaves_other_fields_unchanged(): ws = WorkspaceMock( subscription_id=SUBSCRIPTION_ID, From 59a3d741a6bde5ec5a2305b1f6c7912933ea4aea Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Mon, 21 Sep 2026 10:51:37 -0700 Subject: [PATCH 5/8] Safely select attachment container SAS URI --- azure-quantum/azure/quantum/job/base_job.py | 55 +++++++++++------ azure-quantum/tests/test_job_attachments.py | 68 +++++++++++++++++++-- 2 files changed, 100 insertions(+), 23 deletions(-) diff --git a/azure-quantum/azure/quantum/job/base_job.py b/azure-quantum/azure/quantum/job/base_job.py index f6abaee9..c968e987 100644 --- a/azure-quantum/azure/quantum/job/base_job.py +++ b/azure-quantum/azure/quantum/job/base_job.py @@ -344,13 +344,8 @@ def upload_attachment( :rtype: str """ - # Use Job's default container if not specified. self._details.container_uri is - # unsigned, so always fetch a fresh SAS-signed URI instead of reusing it. if container_uri is None: - container_uri = self.workspace.get_container_uri( - job_id=self.id, - container_name=self.container_name, - ) + container_uri = self._get_attachment_container_uri() uploaded_blob_uri = self.upload_input_data( container_uri = container_uri, @@ -378,13 +373,8 @@ def download_attachment( :rtype: bytes """ - # Use Job's default container if not specified. self._details.container_uri is - # unsigned, so always fetch a fresh SAS-signed URI instead of reusing it. if container_uri is None: - container_uri = self.workspace.get_container_uri( - job_id=self.id, - container_name=self.container_name, - ) + container_uri = self._get_attachment_container_uri() container_client = ContainerClient.from_container_url(container_uri) blob_client = container_client.get_blob_client(name) @@ -401,17 +391,46 @@ def list_attachments(self) -> list[BlobProperties]: :rtype: list[~azure.storage.blob.BlobProperties] """ - # Use the job's linked storage container. self._details.container_uri is unsigned, - # so always fetch a fresh SAS-signed URI instead of reusing it. - container_uri = self.workspace.get_container_uri( - job_id=self.id, - container_name=self.container_name, - ) + container_uri = self._get_attachment_container_uri() container_client = ContainerClient.from_container_url(container_uri) return list(container_client.list_blobs()) + def _get_attachment_container_uri(self) -> str: + container_uri = self._details.container_uri + if container_uri is None: + return self.workspace.get_container_uri(job_id=self.id) + + query_params = parse_qs(urlparse(container_uri).query) + token_expire_query_param = query_params.get("se") + if query_params.get("sig") and token_expire_query_param: + try: + token_expire_time = datetime.fromisoformat( + token_expire_query_param[0].replace("Z", "+00:00") + ) + if token_expire_time.tzinfo is None: + token_expire_time = token_expire_time.replace(tzinfo=timezone.utc) + if datetime.now(tz=timezone.utc) < token_expire_time - timedelta(minutes=5): + return container_uri + except ValueError: + pass + + refreshed_container_uri = self.workspace.get_container_uri( + job_id=self.id, + container_name=self.container_name, + ) + stored_hostname = urlparse(container_uri).hostname + refreshed_hostname = urlparse(refreshed_container_uri).hostname + if stored_hostname != refreshed_hostname: + raise ValueError( + "Refreshed attachment container hostname " + f"'{refreshed_hostname}' does not match job container hostname " + f"'{stored_hostname}'." + ) + return refreshed_container_uri + + def _get_blob_uri_with_sas_token(self, blob_uri: str) -> str: """Get Blob URI with SAS-token if one was not specified in blob_uri parameter :param blob_uri: Blob URI diff --git a/azure-quantum/tests/test_job_attachments.py b/azure-quantum/tests/test_job_attachments.py index 95ddda66..29b0bde0 100644 --- a/azure-quantum/tests/test_job_attachments.py +++ b/azure-quantum/tests/test_job_attachments.py @@ -4,13 +4,17 @@ ## from unittest.mock import Mock, call, patch + +import pytest + from azure.quantum import Job, JobDetails JOB_ID = "job-id" DEFAULT_CONTAINER_NAME = f"job-{JOB_ID}" UNSIGNED_CONTAINER_URI = f"https://acct.blob.core.windows.net/{DEFAULT_CONTAINER_NAME}" -SIGNED_CONTAINER_URI = f"{UNSIGNED_CONTAINER_URI}?sas" +SIGNED_CONTAINER_URI = f"{UNSIGNED_CONTAINER_URI}?se=2099-01-01T00%3A00%3A00Z&sig=signature" +EXPIRED_CONTAINER_URI = f"{UNSIGNED_CONTAINER_URI}?se=2000-01-01T00%3A00%3A00Z&sig=signature" def _job_with_container(container_uri=UNSIGNED_CONTAINER_URI, workspace=None) -> Job: @@ -58,10 +62,7 @@ def test_list_attachments_uses_workspace_container_when_unset(mock_container_cli result = job.list_attachments() - workspace.get_container_uri.assert_called_once_with( - job_id=JOB_ID, - container_name=DEFAULT_CONTAINER_NAME, - ) + workspace.get_container_uri.assert_called_once_with(job_id=JOB_ID) mock_container_client.from_container_url.assert_called_once_with(SIGNED_CONTAINER_URI) assert result == [] @@ -125,6 +126,63 @@ def test_attachment_methods_honor_explicit_container_uri(mock_container_client): mock_container_client.from_container_url.assert_called_once_with(explicit_uri) +@patch("azure.quantum.job.base_job.ContainerClient") +def test_attachment_methods_reuse_valid_signed_job_uri(mock_container_client): + workspace = Mock() + job = _job_with_container(container_uri=SIGNED_CONTAINER_URI, workspace=workspace) + job.upload_input_data = Mock(return_value="uploaded-uri") + container_client = mock_container_client.from_container_url.return_value + container_client.list_blobs.return_value = [] + container_client.get_blob_client.return_value.download_blob.return_value.readall.return_value = b"data" + + job.upload_attachment("upload", b"data") + attachments = job.list_attachments() + downloaded = job.download_attachment("download") + + workspace.get_container_uri.assert_not_called() + job.upload_input_data.assert_called_once_with( + container_uri=SIGNED_CONTAINER_URI, + blob_name="upload", + input_data=b"data", + ) + assert mock_container_client.from_container_url.call_args_list == [ + call(SIGNED_CONTAINER_URI), + call(SIGNED_CONTAINER_URI), + ] + assert attachments == [] + assert downloaded == b"data" + + +def test_upload_attachment_refreshes_expired_job_uri(): + workspace = Mock() + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI + job = _job_with_container(container_uri=EXPIRED_CONTAINER_URI, workspace=workspace) + job.upload_input_data = Mock(return_value="uploaded-uri") + + job.upload_attachment("attachment", b"data") + + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) + job.upload_input_data.assert_called_once_with( + container_uri=SIGNED_CONTAINER_URI, + blob_name="attachment", + input_data=b"data", + ) + + +def test_upload_attachment_rejects_refreshed_storage_hostname_mismatch(): + workspace = Mock() + workspace.get_container_uri.return_value = ( + f"https://other-acct.blob.core.windows.net/{DEFAULT_CONTAINER_NAME}?sas" + ) + job = _job_with_container(workspace=workspace) + + with pytest.raises(ValueError, match="does not match job container hostname"): + job.upload_attachment("attachment", b"data") + + @patch("azure.quantum.job.base_job.ContainerClient") def test_attachment_methods_preserve_custom_container_name(mock_container_client): custom_container_name = "custom-container" From bf19c4b68be0cadf1e81ca605fec00f39652cced Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Mon, 21 Sep 2026 11:06:29 -0700 Subject: [PATCH 6/8] Refresh attachment SAS when permissions are insufficient --- azure-quantum/azure/quantum/job/base_job.py | 15 ++++--- azure-quantum/tests/test_job_attachments.py | 50 ++++++++++++++++++++- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/azure-quantum/azure/quantum/job/base_job.py b/azure-quantum/azure/quantum/job/base_job.py index c968e987..8c52e1d6 100644 --- a/azure-quantum/azure/quantum/job/base_job.py +++ b/azure-quantum/azure/quantum/job/base_job.py @@ -345,7 +345,7 @@ def upload_attachment( """ if container_uri is None: - container_uri = self._get_attachment_container_uri() + container_uri = self._get_attachment_container_uri(required_permission="w") uploaded_blob_uri = self.upload_input_data( container_uri = container_uri, @@ -374,7 +374,7 @@ def download_attachment( """ if container_uri is None: - container_uri = self._get_attachment_container_uri() + container_uri = self._get_attachment_container_uri(required_permission="r") container_client = ContainerClient.from_container_url(container_uri) blob_client = container_client.get_blob_client(name) @@ -391,20 +391,25 @@ def list_attachments(self) -> list[BlobProperties]: :rtype: list[~azure.storage.blob.BlobProperties] """ - container_uri = self._get_attachment_container_uri() + container_uri = self._get_attachment_container_uri(required_permission="l") container_client = ContainerClient.from_container_url(container_uri) return list(container_client.list_blobs()) - def _get_attachment_container_uri(self) -> str: + def _get_attachment_container_uri(self, required_permission: str) -> str: container_uri = self._details.container_uri if container_uri is None: return self.workspace.get_container_uri(job_id=self.id) query_params = parse_qs(urlparse(container_uri).query) token_expire_query_param = query_params.get("se") - if query_params.get("sig") and token_expire_query_param: + token_permissions = query_params.get("sp", [""])[0] + if ( + query_params.get("sig") + and token_expire_query_param + and required_permission in token_permissions + ): try: token_expire_time = datetime.fromisoformat( token_expire_query_param[0].replace("Z", "+00:00") diff --git a/azure-quantum/tests/test_job_attachments.py b/azure-quantum/tests/test_job_attachments.py index 29b0bde0..6e84a4b9 100644 --- a/azure-quantum/tests/test_job_attachments.py +++ b/azure-quantum/tests/test_job_attachments.py @@ -13,8 +13,15 @@ JOB_ID = "job-id" DEFAULT_CONTAINER_NAME = f"job-{JOB_ID}" UNSIGNED_CONTAINER_URI = f"https://acct.blob.core.windows.net/{DEFAULT_CONTAINER_NAME}" -SIGNED_CONTAINER_URI = f"{UNSIGNED_CONTAINER_URI}?se=2099-01-01T00%3A00%3A00Z&sig=signature" -EXPIRED_CONTAINER_URI = f"{UNSIGNED_CONTAINER_URI}?se=2000-01-01T00%3A00%3A00Z&sig=signature" +SIGNED_CONTAINER_URI = ( + f"{UNSIGNED_CONTAINER_URI}?sp=racwdl&se=2099-01-01T00%3A00%3A00Z&sig=signature" +) +READ_ONLY_CONTAINER_URI = ( + f"{UNSIGNED_CONTAINER_URI}?sp=rl&se=2099-01-01T00%3A00%3A00Z&sig=signature" +) +EXPIRED_CONTAINER_URI = ( + f"{UNSIGNED_CONTAINER_URI}?sp=racwdl&se=2000-01-01T00%3A00%3A00Z&sig=signature" +) def _job_with_container(container_uri=UNSIGNED_CONTAINER_URI, workspace=None) -> Job: @@ -172,6 +179,45 @@ def test_upload_attachment_refreshes_expired_job_uri(): ) +def test_upload_attachment_refreshes_job_uri_without_write_permission(): + workspace = Mock() + workspace.get_container_uri.return_value = SIGNED_CONTAINER_URI + job = _job_with_container(container_uri=READ_ONLY_CONTAINER_URI, workspace=workspace) + job.upload_input_data = Mock(return_value="uploaded-uri") + + job.upload_attachment("attachment", b"data") + + workspace.get_container_uri.assert_called_once_with( + job_id=JOB_ID, + container_name=DEFAULT_CONTAINER_NAME, + ) + job.upload_input_data.assert_called_once_with( + container_uri=SIGNED_CONTAINER_URI, + blob_name="attachment", + input_data=b"data", + ) + + +@patch("azure.quantum.job.base_job.ContainerClient") +def test_read_only_job_uri_is_reused_for_list_and_download(mock_container_client): + workspace = Mock() + job = _job_with_container(container_uri=READ_ONLY_CONTAINER_URI, workspace=workspace) + container_client = mock_container_client.from_container_url.return_value + container_client.list_blobs.return_value = [] + container_client.get_blob_client.return_value.download_blob.return_value.readall.return_value = b"data" + + attachments = job.list_attachments() + downloaded = job.download_attachment("download") + + workspace.get_container_uri.assert_not_called() + assert mock_container_client.from_container_url.call_args_list == [ + call(READ_ONLY_CONTAINER_URI), + call(READ_ONLY_CONTAINER_URI), + ] + assert attachments == [] + assert downloaded == b"data" + + def test_upload_attachment_rejects_refreshed_storage_hostname_mismatch(): workspace = Mock() workspace.get_container_uri.return_value = ( From 561104998d67c0e39e3da50eb0b7962df2cd52d2 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Mon, 21 Sep 2026 11:29:06 -0700 Subject: [PATCH 7/8] Allow listing with connection-string container SAS --- azure-quantum/azure/quantum/storage.py | 5 +++-- azure-quantum/tests/test_storage.py | 28 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 azure-quantum/tests/test_storage.py diff --git a/azure-quantum/azure/quantum/storage.py b/azure-quantum/azure/quantum/storage.py index e68ea176..82cb348f 100644 --- a/azure-quantum/azure/quantum/storage.py +++ b/azure-quantum/azure/quantum/storage.py @@ -10,6 +10,7 @@ ContainerClient, BlobClient, BlobSasPermissions, + ContainerSasPermissions, ContentSettings, generate_blob_sas, generate_container_sas, @@ -68,8 +69,8 @@ def get_container_uri(connection_string: str, container_name: str) -> str: container.account_name, container.container_name, account_key=container.credential.account_key, - permission=BlobSasPermissions( - read=True, add=True, write=True, create=True + permission=ContainerSasPermissions( + read=True, add=True, write=True, create=True, list=True ), expiry=datetime.utcnow() + timedelta(days=14), ) diff --git a/azure-quantum/tests/test_storage.py b/azure-quantum/tests/test_storage.py new file mode 100644 index 00000000..6bedef26 --- /dev/null +++ b/azure-quantum/tests/test_storage.py @@ -0,0 +1,28 @@ +## +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +## + +from unittest.mock import Mock, patch + +from azure.storage.blob import ContainerSasPermissions + +from azure.quantum.storage import get_container_uri + + +@patch("azure.quantum.storage.generate_container_sas", return_value="sas-token") +@patch("azure.quantum.storage.create_container") +def test_get_container_uri_sas_allows_listing(mock_create_container, mock_generate_sas): + container = Mock() + container.account_name = "account" + container.container_name = "container" + container.url = "https://account.blob.core.windows.net/container" + container.credential.account_key = "account-key" + mock_create_container.return_value = container + + result = get_container_uri("connection-string", "container") + + permission = mock_generate_sas.call_args.kwargs["permission"] + assert isinstance(permission, ContainerSasPermissions) + assert str(permission) == "racwl" + assert result == "https://account.blob.core.windows.net/container?sas-token" \ No newline at end of file From a9c1506fb1b165bcf151be2a228daf74b9da3235 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Mon, 21 Sep 2026 15:42:25 -0700 Subject: [PATCH 8/8] Limit attachment fix to linked storage --- azure-quantum/azure/quantum/storage.py | 5 ++--- azure-quantum/tests/test_storage.py | 28 -------------------------- 2 files changed, 2 insertions(+), 31 deletions(-) delete mode 100644 azure-quantum/tests/test_storage.py diff --git a/azure-quantum/azure/quantum/storage.py b/azure-quantum/azure/quantum/storage.py index 82cb348f..e68ea176 100644 --- a/azure-quantum/azure/quantum/storage.py +++ b/azure-quantum/azure/quantum/storage.py @@ -10,7 +10,6 @@ ContainerClient, BlobClient, BlobSasPermissions, - ContainerSasPermissions, ContentSettings, generate_blob_sas, generate_container_sas, @@ -69,8 +68,8 @@ def get_container_uri(connection_string: str, container_name: str) -> str: container.account_name, container.container_name, account_key=container.credential.account_key, - permission=ContainerSasPermissions( - read=True, add=True, write=True, create=True, list=True + permission=BlobSasPermissions( + read=True, add=True, write=True, create=True ), expiry=datetime.utcnow() + timedelta(days=14), ) diff --git a/azure-quantum/tests/test_storage.py b/azure-quantum/tests/test_storage.py deleted file mode 100644 index 6bedef26..00000000 --- a/azure-quantum/tests/test_storage.py +++ /dev/null @@ -1,28 +0,0 @@ -## -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -## - -from unittest.mock import Mock, patch - -from azure.storage.blob import ContainerSasPermissions - -from azure.quantum.storage import get_container_uri - - -@patch("azure.quantum.storage.generate_container_sas", return_value="sas-token") -@patch("azure.quantum.storage.create_container") -def test_get_container_uri_sas_allows_listing(mock_create_container, mock_generate_sas): - container = Mock() - container.account_name = "account" - container.container_name = "container" - container.url = "https://account.blob.core.windows.net/container" - container.credential.account_key = "account-key" - mock_create_container.return_value = container - - result = get_container_uri("connection-string", "container") - - permission = mock_generate_sas.call_args.kwargs["permission"] - assert isinstance(permission, ContainerSasPermissions) - assert str(permission) == "racwl" - assert result == "https://account.blob.core.windows.net/container?sas-token" \ No newline at end of file