Skip to content

Commit f2d4976

Browse files
adrian-gavrilaCopilotromanlutz
authored
MAINT: Use DefaultAzureCredential directly for blob storage auth (microsoft#1946)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Roman Lutz <romanlutz13@gmail.com>
1 parent 765677a commit f2d4976

4 files changed

Lines changed: 221 additions & 41 deletions

File tree

pyrit/memory/storage/storage.py

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from pyrit.common.deprecation import print_deprecation_message
1616

1717
if TYPE_CHECKING:
18+
from azure.identity.aio import DefaultAzureCredential
1819
from azure.storage.blob.aio import ContainerClient as AsyncContainerClient
1920

2021
logger = logging.getLogger(__name__)
@@ -264,33 +265,63 @@ def __init__(
264265
self._container_url: str = container_url
265266
self._sas_token = sas_token
266267
self._client_async: AsyncContainerClient | None = None
268+
self._credential: DefaultAzureCredential | None = None
267269

268270
async def _create_container_client_async(self) -> AsyncContainerClient:
269271
"""
270272
Create an asynchronous ContainerClient for Azure Storage.
271273
272274
If a SAS token is provided via the
273275
AZURE_STORAGE_ACCOUNT_SAS_TOKEN environment variable or the init sas_token parameter, it will be used
274-
for authentication. Otherwise, a delegation SAS token will be created using Entra ID authentication.
276+
for authentication. Otherwise, ``DefaultAzureCredential`` is used directly, which requires the caller
277+
to hold a data-plane role such as Storage Blob Data Contributor on the storage account.
275278
276279
Returns:
277280
AsyncContainerClient: The initialized container client.
281+
282+
Raises:
283+
ValueError: If the container URL does not include a container name in its path.
278284
"""
279285
from azure.storage.blob.aio import ContainerClient as AsyncContainerClient
280286

281-
from pyrit.auth import AzureStorageAuth
287+
if self._sas_token:
288+
self._client_async = AsyncContainerClient.from_container_url(
289+
container_url=self._container_url,
290+
credential=self._sas_token,
291+
)
292+
return self._client_async
282293

283-
sas_token = self._sas_token
284-
if not self._sas_token:
285-
logger.info("SAS token not provided. Creating a delegation SAS token using Entra ID authentication.")
286-
sas_token = await AzureStorageAuth.get_sas_token_async(self._container_url)
294+
from azure.identity.aio import DefaultAzureCredential
287295

288-
self._client_async = AsyncContainerClient.from_container_url(
289-
container_url=self._container_url,
290-
credential=sas_token,
296+
logger.info("SAS token not provided. Using DefaultAzureCredential for direct Entra ID authentication.")
297+
parsed_url = urlparse(self._container_url)
298+
path_parts = [part for part in parsed_url.path.split("/") if part]
299+
if not path_parts:
300+
raise ValueError(
301+
f"Invalid Azure Storage container URL '{self._container_url}': expected a container name in the "
302+
"path, e.g. https://<account>.blob.core.windows.net/<container>."
303+
)
304+
account_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
305+
container_name = path_parts[0]
306+
self._credential = DefaultAzureCredential()
307+
self._client_async = AsyncContainerClient(
308+
account_url=account_url,
309+
container_name=container_name,
310+
credential=self._credential,
291311
)
292312
return self._client_async
293313

314+
async def _close_client_async(self) -> None:
315+
"""Close the container client and credential, resetting both to None."""
316+
client, self._client_async = self._client_async, None
317+
credential, self._credential = self._credential, None
318+
try:
319+
if client:
320+
await client.close() # type: ignore[no-untyped-call, unused-ignore]
321+
finally:
322+
if credential:
323+
await credential.close()
324+
294325
async def _upload_blob_async(self, file_name: str, data: bytes, content_type: str) -> None:
295326
"""
296327
(Async) Handles uploading blob to given storage container.
@@ -321,10 +352,10 @@ async def _upload_blob_async(self, file_name: str, data: bytes, content_type: st
321352
except Exception as exc:
322353
if isinstance(exc, ClientAuthenticationError):
323354
logger.exception(
324-
msg="Authentication failed. Please check that the container existence in the "
325-
"Azure Storage Account and ensure the validity of the provided SAS token. If you "
326-
"haven't set the SAS token as an environment variable use `az login` to "
327-
"enable delegation-based SAS authentication to connect to the storage account"
355+
msg="Authentication failed. Please check that the container exists in the "
356+
"Azure Storage Account. If using a SAS token, ensure it is valid. Otherwise, "
357+
"ensure you are logged in via `az login` and hold a data-plane role such as "
358+
"Storage Blob Data Contributor on the storage account."
328359
)
329360
raise
330361
logger.exception(msg=f"An unexpected error occurred: {exc}")
@@ -417,8 +448,7 @@ async def read_file_async(self, path: Path | str) -> bytes:
417448
logger.exception(f"Failed to read file at {blob_name}: {exc}")
418449
raise
419450
finally:
420-
await self._client_async.close()
421-
self._client_async = None
451+
await self._close_client_async()
422452

423453
async def write_file_async(self, path: Path | str, data: bytes) -> None:
424454
"""
@@ -440,8 +470,7 @@ async def write_file_async(self, path: Path | str, data: bytes) -> None:
440470
logger.exception(f"Failed to write file at {blob_name}: {exc}")
441471
raise
442472
finally:
443-
await self._client_async.close()
444-
self._client_async = None
473+
await self._close_client_async()
445474

446475
async def path_exists_async(self, path: Path | str) -> bool:
447476
"""
@@ -465,8 +494,7 @@ async def path_exists_async(self, path: Path | str) -> bool:
465494
except ResourceNotFoundError:
466495
return False
467496
finally:
468-
await self._client_async.close()
469-
self._client_async = None
497+
await self._close_client_async()
470498

471499
async def is_file_async(self, path: Path | str) -> bool:
472500
"""
@@ -490,8 +518,7 @@ async def is_file_async(self, path: Path | str) -> bool:
490518
except ResourceNotFoundError:
491519
return False
492520
finally:
493-
await self._client_async.close()
494-
self._client_async = None
521+
await self._close_client_async()
495522

496523
async def create_directory_if_not_exists_async(self, directory_path: Path | str) -> None: # type: ignore[ty:invalid-method-override]
497524
"""

pyrit/prompt_target/azure_blob_storage_target.py

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
from urllib.parse import urlparse
77

88
from azure.core.exceptions import ClientAuthenticationError
9+
from azure.identity.aio import DefaultAzureCredential
910
from azure.storage.blob import ContentSettings
1011
from azure.storage.blob.aio import ContainerClient as AsyncContainerClient
1112

12-
from pyrit.auth import AzureStorageAuth
1313
from pyrit.common import default_values
1414
from pyrit.models import ComponentIdentifier, Message, construct_response_from_request
1515
from pyrit.prompt_target.common.prompt_target import PromptTarget
@@ -37,8 +37,9 @@ class AzureBlobStorageTarget(PromptTarget):
3737
3838
Args:
3939
container_url (str): URL to the Azure Blob Storage Container.
40-
sas_token (optional[str]): Optional Blob SAS token needed to authenticate blob operations. If not provided, a
41-
delegation SAS token will be created using Entra ID authentication.
40+
sas_token (optional[str]): Optional Blob SAS token needed to authenticate blob operations. If not provided,
41+
``DefaultAzureCredential`` is used directly, which requires the caller to hold a data-plane role such
42+
as Storage Blob Data Contributor on the storage account.
4243
blob_content_type (SupportedContentType): Expected Content Type of the blob, chosen from the
4344
SupportedContentType enum. Set to PLAIN_TEXT by default.
4445
max_requests_per_minute (int, Optional): Number of requests the target can handle per
@@ -96,6 +97,7 @@ def __init__(
9697

9798
self._sas_token: str | None = sas_token
9899
self._client_async: AsyncContainerClient | None = None
100+
self._credential: DefaultAzureCredential | None = None
99101

100102
super().__init__(
101103
endpoint=self._container_url,
@@ -121,22 +123,42 @@ async def _create_container_client_async(self) -> None:
121123
"""
122124
Create an asynchronous ContainerClient for Azure Storage. If a SAS token is provided via the
123125
AZURE_STORAGE_ACCOUNT_SAS_TOKEN environment variable or the init sas_token parameter, it will be used
124-
for authentication. Otherwise, a delegation SAS token will be created using Entra ID authentication.
126+
for authentication. Otherwise, ``DefaultAzureCredential`` is used directly, which requires the caller
127+
to hold a data-plane role such as Storage Blob Data Contributor on the storage account.
125128
"""
126129
container_url, _ = self._parse_url()
127130
try:
128131
sas_token: str = default_values.get_required_value(
129132
env_var_name=self.SAS_TOKEN_ENVIRONMENT_VARIABLE, passed_value=self._sas_token
130133
)
131-
logger.info("Using SAS token from environment variable or passed parameter.")
132134
except ValueError:
133-
logger.info("SAS token not provided. Creating a delegation SAS token using Entra ID authentication.")
134-
sas_token = await AzureStorageAuth.get_sas_token_async(container_url)
135+
logger.info("SAS token not provided. Using DefaultAzureCredential for direct Entra ID authentication.")
136+
account_url, _, container_name = container_url.rpartition("/")
137+
self._credential = DefaultAzureCredential()
138+
self._client_async = AsyncContainerClient(
139+
account_url=account_url,
140+
container_name=container_name,
141+
credential=self._credential,
142+
)
143+
return
144+
145+
logger.info("Using SAS token from environment variable or passed parameter.")
135146
self._client_async = AsyncContainerClient.from_container_url(
136147
container_url=container_url,
137148
credential=sas_token,
138149
)
139150

151+
async def _close_client_async(self) -> None:
152+
"""Close the container client and credential, resetting both to None."""
153+
client, self._client_async = self._client_async, None
154+
credential, self._credential = self._credential, None
155+
try:
156+
if client:
157+
await client.close()
158+
finally:
159+
if credential:
160+
await credential.close()
161+
140162
async def _upload_blob_async(self, file_name: str, data: bytes, content_type: str) -> None:
141163
"""
142164
(Async) Handles uploading blob to given storage container.
@@ -152,14 +174,14 @@ async def _upload_blob_async(self, file_name: str, data: bytes, content_type: st
152174
content_settings = ContentSettings(content_type=f"{content_type}")
153175
logger.info(msg="\nUploading to Azure Storage as blob:\n\t" + file_name)
154176

155-
if not self._client_async:
156-
await self._create_container_client_async()
157177
# Parse the Azure Storage Blob URL to extract components
158178
_, blob_prefix = self._parse_url()
159179
# If a blob prefix is provided, prepend it to the file name.
160180
# If not, the file will be put in the root of the container.
161181
blob_path = f"{blob_prefix}/{file_name}" if blob_prefix else file_name
162182
try:
183+
if not self._client_async:
184+
await self._create_container_client_async()
163185
if self._client_async is None:
164186
raise RuntimeError("Blob storage client not initialized")
165187
blob_client = self._client_async.get_blob_client(blob=blob_path)
@@ -171,24 +193,34 @@ async def _upload_blob_async(self, file_name: str, data: bytes, content_type: st
171193
except Exception as exc:
172194
if isinstance(exc, ClientAuthenticationError):
173195
logger.exception(
174-
msg="Authentication failed. Please check that the container existence in the "
175-
"Azure Storage Account and ensure the validity of the provided SAS token. If you "
176-
"haven't set the SAS token as an environment variable use `az login` to "
177-
"enable delegation-based SAS authentication to connect to the storage account"
196+
msg="Authentication failed. Please check that the container exists in the "
197+
"Azure Storage Account. If using a SAS token, ensure it is valid. Otherwise, "
198+
"ensure you are logged in via `az login` and hold a data-plane role such as "
199+
"Storage Blob Data Contributor on the storage account."
178200
)
179201
raise
180202
logger.exception(msg=f"An unexpected error occurred: {exc}")
181203
raise
204+
finally:
205+
await self._close_client_async()
182206

183207
def _parse_url(self) -> tuple[str, str]:
184208
"""
185209
Parse the Azure Storage Blob URL to extract components.
186210
187211
Returns:
188212
tuple: A tuple containing the container URL and blob prefix.
213+
214+
Raises:
215+
ValueError: If the container URL does not include a container name in its path.
189216
"""
190217
parsed_url = urlparse(self._container_url)
191218
path_parts = parsed_url.path.split("/")
219+
if len(path_parts) < 2 or not path_parts[1]:
220+
raise ValueError(
221+
f"Invalid Azure Storage container URL '{self._container_url}': expected a container name in the "
222+
"path, e.g. https://<account>.blob.core.windows.net/<container>."
223+
)
192224
container_name = path_parts[1]
193225
blob_prefix = "/".join(path_parts[2:])
194226
container_url = f"https://{parsed_url.netloc}/{container_name}"

tests/unit/memory/storage/test_storage.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -169,20 +169,60 @@ async def test_azure_blob_storage_io_create_container_client_uses_explicit_sas_t
169169

170170
mock_container_client = AsyncMock()
171171

172+
with patch(
173+
"azure.storage.blob.aio.ContainerClient.from_container_url", return_value=mock_container_client
174+
) as mock_from_container_url:
175+
await azure_blob_storage_io._create_container_client_async()
176+
177+
mock_from_container_url.assert_called_once_with(container_url=container_url, credential=sas_token)
178+
assert azure_blob_storage_io._client_async is mock_container_client
179+
assert azure_blob_storage_io._credential is None
180+
181+
182+
async def test_azure_blob_storage_io_create_container_client_uses_default_credential_when_no_sas_token():
183+
container_url = "https://youraccount.blob.core.windows.net/yourcontainer"
184+
azure_blob_storage_io = AzureBlobStorageIO(container_url=container_url)
185+
186+
mock_container_client = AsyncMock()
187+
mock_credential = AsyncMock()
188+
172189
with (
173-
patch("pyrit.auth.AzureStorageAuth.get_sas_token_async", new_callable=AsyncMock) as mock_get_sas_token,
174-
patch(
175-
"azure.storage.blob.aio.ContainerClient.from_container_url", return_value=mock_container_client
176-
) as mock_from_container_url,
190+
patch("azure.identity.aio.DefaultAzureCredential", return_value=mock_credential) as mock_credential_cls,
191+
patch("azure.storage.blob.aio.ContainerClient", return_value=mock_container_client) as mock_container_cls,
177192
):
178193
await azure_blob_storage_io._create_container_client_async()
179194

180-
mock_get_sas_token.assert_not_awaited()
181-
mock_from_container_url.assert_called_once_with(container_url=container_url, credential=sas_token)
195+
mock_credential_cls.assert_called_once()
196+
mock_container_cls.assert_called_once_with(
197+
account_url="https://youraccount.blob.core.windows.net",
198+
container_name="yourcontainer",
199+
credential=mock_credential,
200+
)
182201
assert azure_blob_storage_io._client_async is mock_container_client
202+
assert azure_blob_storage_io._credential is mock_credential
203+
204+
205+
async def test_azure_blob_storage_io_close_client_async_closes_credential_and_client():
206+
azure_blob_storage_io = AzureBlobStorageIO(container_url="https://youraccount.blob.core.windows.net/yourcontainer")
207+
208+
mock_client = AsyncMock()
209+
mock_credential = AsyncMock()
210+
azure_blob_storage_io._client_async = mock_client
211+
azure_blob_storage_io._credential = mock_credential
183212

213+
await azure_blob_storage_io._close_client_async()
184214

185-
async def test_azure_storage_io_path_exists(azure_blob_storage_io):
215+
mock_client.close.assert_awaited_once()
216+
mock_credential.close.assert_awaited_once()
217+
assert azure_blob_storage_io._client_async is None
218+
assert azure_blob_storage_io._credential is None
219+
220+
221+
async def test_azure_blob_storage_io_create_container_client_raises_for_url_without_container():
222+
azure_blob_storage_io = AzureBlobStorageIO(container_url="https://youraccount.blob.core.windows.net")
223+
224+
with pytest.raises(ValueError, match="expected a container name"):
225+
await azure_blob_storage_io._create_container_client_async()
186226
azure_blob_storage_io._client_async = AsyncMock()
187227

188228
mock_blob_client = AsyncMock()

0 commit comments

Comments
 (0)