Skip to content

Commit d855bd7

Browse files
hallvictoriaVictoria Hall
andauthored
feat: supporting managed identity (#45)
* Error if invalid connection * Error for all types * Validate during type init * Missed refs * Removed testing line * Managed identity for Blob Client * Secrets typo * Secrets typo * MI for CC and SSD * lint * reorder checks * remove duplicate test * refactor env var check, client return * fix tests * refactor client creation, safe dict lookup --------- Co-authored-by: Victoria Hall <[email protected]>
1 parent 4f8ac0e commit d855bd7

File tree

8 files changed

+261
-38
lines changed

8 files changed

+261
-38
lines changed

azurefunctions-extensions-bindings-blob/azurefunctions/extensions/bindings/blob/blobClient.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@
44
import json
55
from typing import Union
66

7-
from azure.storage.blob import BlobClient as BlobClientSdk
7+
from azure.storage.blob import BlobServiceClient
88
from azurefunctions.extensions.base import Datum, SdkType
9-
from .utils import validate_connection_string
9+
from .utils import get_connection_string, using_managed_identity
1010

1111

1212
class BlobClient(SdkType):
1313
def __init__(self, *, data: Union[bytes, Datum]) -> None:
1414
# model_binding_data properties
1515
self._data = data
16+
self._using_managed_identity = False
1617
self._version = None
1718
self._source = None
1819
self._content_type = None
@@ -24,17 +25,32 @@ def __init__(self, *, data: Union[bytes, Datum]) -> None:
2425
self._source = data.source
2526
self._content_type = data.content_type
2627
content_json = json.loads(data.content)
27-
self._connection = validate_connection_string(content_json["Connection"])
28-
self._containerName = content_json["ContainerName"]
29-
self._blobName = content_json["BlobName"]
28+
self._connection = get_connection_string(content_json.get("Connection"))
29+
self._using_managed_identity = using_managed_identity(
30+
content_json.get("Connection")
31+
)
32+
self._containerName = content_json.get("ContainerName")
33+
self._blobName = content_json.get("BlobName")
3034

31-
# Returns a BlobClient
3235
def get_sdk_type(self):
36+
"""
37+
When using Managed Identity, the only way to create a BlobClient is
38+
through a BlobServiceClient. There are two ways to create a
39+
BlobServiceClient:
40+
1. Through the constructor: this is the only option when using Managed Identity
41+
2. Through from_connection_string: this is the only option when not using Managed Identity
42+
43+
We track if Managed Identity is being used through a flag.
44+
"""
3345
if self._data:
34-
return BlobClientSdk.from_connection_string(
35-
conn_str=self._connection,
36-
container_name=self._containerName,
37-
blob_name=self._blobName,
46+
blob_service_client = (
47+
BlobServiceClient(account_url=self._connection)
48+
if self._using_managed_identity
49+
else BlobServiceClient.from_connection_string(self._connection)
50+
)
51+
return blob_service_client.get_blob_client(
52+
container=self._containerName,
53+
blob=self._blobName,
3854
)
3955
else:
4056
return None

azurefunctions-extensions-bindings-blob/azurefunctions/extensions/bindings/blob/containerClient.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@
44
import json
55
from typing import Union
66

7-
from azure.storage.blob import ContainerClient as ContainerClientSdk
7+
from azure.storage.blob import BlobServiceClient
88
from azurefunctions.extensions.base import Datum, SdkType
9-
from .utils import validate_connection_string
9+
from .utils import get_connection_string, using_managed_identity
1010

1111

1212
class ContainerClient(SdkType):
1313
def __init__(self, *, data: Union[bytes, Datum]) -> None:
1414
# model_binding_data properties
1515
self._data = data
16+
self._using_managed_identity = False
1617
self._version = ""
1718
self._source = ""
1819
self._content_type = ""
@@ -24,15 +25,23 @@ def __init__(self, *, data: Union[bytes, Datum]) -> None:
2425
self._source = data.source
2526
self._content_type = data.content_type
2627
content_json = json.loads(data.content)
27-
self._connection = validate_connection_string(content_json["Connection"])
28-
self._containerName = content_json["ContainerName"]
29-
self._blobName = content_json["BlobName"]
28+
self._connection = get_connection_string(content_json.get("Connection"))
29+
self._using_managed_identity = using_managed_identity(
30+
content_json.get("Connection")
31+
)
32+
self._containerName = content_json.get("ContainerName")
33+
self._blobName = content_json.get("BlobName")
3034

3135
# Returns a ContainerClient
3236
def get_sdk_type(self):
3337
if self._data:
34-
return ContainerClientSdk.from_connection_string(
35-
conn_str=self._connection, container_name=self._containerName
38+
blob_service_client = (
39+
BlobServiceClient(account_url=self._connection)
40+
if self._using_managed_identity
41+
else BlobServiceClient.from_connection_string(self._connection)
42+
)
43+
return blob_service_client.get_container_client(
44+
container=self._containerName
3645
)
3746
else:
3847
return None

azurefunctions-extensions-bindings-blob/azurefunctions/extensions/bindings/blob/storageStreamDownloader.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@
44
import json
55
from typing import Union
66

7-
from azure.storage.blob import BlobClient as BlobClientSdk
7+
from azure.storage.blob import BlobServiceClient
88
from azurefunctions.extensions.base import Datum, SdkType
9-
from .utils import validate_connection_string
9+
from .utils import get_connection_string, using_managed_identity
1010

1111

1212
class StorageStreamDownloader(SdkType):
1313
def __init__(self, *, data: Union[bytes, Datum]) -> None:
1414
# model_binding_data properties
15-
self._data = data or {}
15+
self._data = data
16+
self._using_managed_identity = False
1617
self._version = ""
1718
self._source = ""
1819
self._content_type = ""
@@ -24,20 +25,25 @@ def __init__(self, *, data: Union[bytes, Datum]) -> None:
2425
self._source = data.source
2526
self._content_type = data.content_type
2627
content_json = json.loads(data.content)
27-
self._connection = validate_connection_string(content_json["Connection"])
28-
self._containerName = content_json["ContainerName"]
29-
self._blobName = content_json["BlobName"]
28+
self._connection = get_connection_string(content_json.get("Connection"))
29+
self._using_managed_identity = using_managed_identity(
30+
content_json.get("Connection")
31+
)
32+
self._containerName = content_json.get("ContainerName")
33+
self._blobName = content_json.get("BlobName")
3034

3135
# Returns a StorageStreamDownloader
3236
def get_sdk_type(self):
3337
if self._data:
34-
# Create BlobClient
35-
blob_client = BlobClientSdk.from_connection_string(
36-
conn_str=self._connection,
37-
container_name=self._containerName,
38-
blob_name=self._blobName,
38+
blob_service_client = (
39+
BlobServiceClient(account_url=self._connection)
40+
if self._using_managed_identity
41+
else BlobServiceClient.from_connection_string(self._connection)
3942
)
4043
# download_blob() returns a StorageStreamDownloader object
41-
return blob_client.download_blob()
44+
return blob_service_client.get_blob_client(
45+
container=self._containerName,
46+
blob=self._blobName,
47+
).download_blob()
4248
else:
4349
return None

azurefunctions-extensions-bindings-blob/azurefunctions/extensions/bindings/blob/utils.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,47 @@
33
import os
44

55

6-
def validate_connection_string(connection_string: str) -> str:
6+
def get_connection_string(connection_string: str) -> str:
77
"""
8-
Validates the connection string. If the connection string is
8+
Validates and returns the connection string. If the connection string is
99
not an App Setting, an error will be thrown.
10+
11+
When using managed identity, the connection string variable name is formatted like so:
12+
Input: <CONNECTION_NAME_PREFIX>__serviceUri
13+
Trigger: <CONNECTION_NAME_PREFIX>__blobServiceUri
14+
The variable received will be <CONNECTION_NAME_PREFIX>. Therefore, we need to append
15+
the suffix to obtain the storage URI and create the client.
16+
17+
There are four cases:
18+
1. Not using managed identity: the environment variable exists as is
19+
2. Using managed identity for blob input: __serviceUri must be appended
20+
3. Using managed identity for blob trigger: __blobServiceUri must be appended
21+
4. None of these cases existed, so the connection variable is invalid.
1022
"""
11-
if connection_string == None:
23+
if connection_string is None:
1224
raise ValueError(
13-
"Storage account connection string cannot be none. "
25+
"Storage account connection string cannot be None. "
1426
"Please provide a connection string."
1527
)
16-
elif not os.getenv(connection_string):
28+
elif connection_string in os.environ:
29+
return os.getenv(connection_string)
30+
elif connection_string + "__serviceUri" in os.environ:
31+
return os.getenv(connection_string + "__serviceUri")
32+
elif connection_string + "__blobServiceUri" in os.environ:
33+
return os.getenv(connection_string + "__blobServiceUri")
34+
else:
1735
raise ValueError(
1836
f"Storage account connection string {connection_string} does not exist. "
1937
f"Please make sure that it is a defined App Setting."
2038
)
21-
return os.getenv(connection_string)
39+
40+
41+
def using_managed_identity(connection_name: str) -> bool:
42+
"""
43+
To determine if managed identity is being used, we check if the provided
44+
connection string has either of the two suffixes:
45+
__serviceUri or __blobServiceUri.
46+
"""
47+
return (os.getenv(connection_name + "__serviceUri") is not None) or (
48+
os.getenv(connection_name + "__blobServiceUri") is not None
49+
)

azurefunctions-extensions-bindings-blob/tests/test_blobclient.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,63 @@ def test_none_input_populated(self):
172172
)
173173
self.assertEqual(
174174
e.exception.args[0],
175-
"Storage account connection string cannot be none. Please provide a connection string.",
175+
"Storage account connection string cannot be None. Please provide a connection string.",
176176
)
177177

178+
def test_input_populated_managed_identity_input(self):
179+
content = {
180+
"Connection": "input",
181+
"ContainerName": "test-blob",
182+
"BlobName": "text.txt",
183+
}
184+
185+
sample_mbd = MockMBD(
186+
version="1.0",
187+
source="AzureStorageBlobs",
188+
content_type="application/json",
189+
content=json.dumps(content),
190+
)
191+
192+
datum: Datum = Datum(value=sample_mbd, type="model_binding_data")
193+
result: BlobClient = BlobClientConverter.decode(
194+
data=datum, trigger_metadata=None, pytype=BlobClient
195+
)
196+
197+
self.assertIsNotNone(result)
198+
self.assertIsInstance(result, BlobClientSdk)
199+
200+
sdk_result = BlobClient(data=datum.value).get_sdk_type()
201+
202+
self.assertIsNotNone(sdk_result)
203+
self.assertIsInstance(sdk_result, BlobClientSdk)
204+
205+
def test_input_populated_managed_identity_trigger(self):
206+
content = {
207+
"Connection": "trigger",
208+
"ContainerName": "test-blob",
209+
"BlobName": "text.txt",
210+
}
211+
212+
sample_mbd = MockMBD(
213+
version="1.0",
214+
source="AzureStorageBlobs",
215+
content_type="application/json",
216+
content=json.dumps(content),
217+
)
218+
219+
datum: Datum = Datum(value=sample_mbd, type="model_binding_data")
220+
result: BlobClient = BlobClientConverter.decode(
221+
data=datum, trigger_metadata=None, pytype=BlobClient
222+
)
223+
224+
self.assertIsNotNone(result)
225+
self.assertIsInstance(result, BlobClientSdk)
226+
227+
sdk_result = BlobClient(data=datum.value).get_sdk_type()
228+
229+
self.assertIsNotNone(sdk_result)
230+
self.assertIsInstance(sdk_result, BlobClientSdk)
231+
178232
def test_input_invalid_pytype(self):
179233
content = {
180234
"Connection": "AzureWebJobsStorage",

azurefunctions-extensions-bindings-blob/tests/test_containerclient.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,63 @@ def test_none_input_populated(self):
170170
)
171171
self.assertEqual(
172172
e.exception.args[0],
173-
"Storage account connection string cannot be none. Please provide a connection string.",
173+
"Storage account connection string cannot be None. Please provide a connection string.",
174174
)
175175

176+
def test_input_populated_managed_identity_input(self):
177+
content = {
178+
"Connection": "input",
179+
"ContainerName": "test-blob",
180+
"BlobName": "text.txt",
181+
}
182+
183+
sample_mbd = MockMBD(
184+
version="1.0",
185+
source="AzureStorageBlobs",
186+
content_type="application/json",
187+
content=json.dumps(content),
188+
)
189+
190+
datum: Datum = Datum(value=sample_mbd, type="model_binding_data")
191+
result: ContainerClient = BlobClientConverter.decode(
192+
data=datum, trigger_metadata=None, pytype=ContainerClient
193+
)
194+
195+
self.assertIsNotNone(result)
196+
self.assertIsInstance(result, ContainerClientSdk)
197+
198+
sdk_result = ContainerClient(data=datum.value).get_sdk_type()
199+
200+
self.assertIsNotNone(sdk_result)
201+
self.assertIsInstance(sdk_result, ContainerClientSdk)
202+
203+
def test_input_populated_managed_identity_trigger(self):
204+
content = {
205+
"Connection": "trigger",
206+
"ContainerName": "test-blob",
207+
"BlobName": "text.txt",
208+
}
209+
210+
sample_mbd = MockMBD(
211+
version="1.0",
212+
source="AzureStorageBlobs",
213+
content_type="application/json",
214+
content=json.dumps(content),
215+
)
216+
217+
datum: Datum = Datum(value=sample_mbd, type="model_binding_data")
218+
result: ContainerClient = BlobClientConverter.decode(
219+
data=datum, trigger_metadata=None, pytype=ContainerClient
220+
)
221+
222+
self.assertIsNotNone(result)
223+
self.assertIsInstance(result, ContainerClientSdk)
224+
225+
sdk_result = ContainerClient(data=datum.value).get_sdk_type()
226+
227+
self.assertIsNotNone(sdk_result)
228+
self.assertIsInstance(sdk_result, ContainerClientSdk)
229+
176230
def test_input_invalid_pytype(self):
177231
content = {
178232
"Connection": "AzureWebJobsStorage",

0 commit comments

Comments
 (0)