Skip to content

Commit 6e534d3

Browse files
author
Michael Baumann
committed
Initial implementation of Azure auth support for DRS
Add support for using Microsoft Azure default credentials when running in a Terra Azure Interactive Analysis Cloud Environment. These credentials are used to auth with Terra backend services, including Martha/terra-drs-hub and Rawls. When running in a Terra Google IA Cloud Environment, the behavior is as before, with no changes. This initial implementation is thought to be functionally complete, yet tests and documentation remain to be added.
1 parent b53bb86 commit 6e534d3

File tree

11 files changed

+179
-8
lines changed

11 files changed

+179
-8
lines changed

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
azure-identity >= 1.12.0, < 2
12
google-cloud-storage >= 1.38.0, < 2
23
gs-chunked-io >= 0.5.1, < 0.6
34
firecloud

terra_notebook_utils/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import os
2+
from dataclasses import dataclass
3+
from enum import Enum
24

35
WORKSPACE_NAME = os.environ.get('WORKSPACE_NAME', None)
46
WORKSPACE_NAMESPACE = os.environ.get('WORKSPACE_NAMESPACE') # This env var is set in Terra Cloud Environments
@@ -26,3 +28,20 @@
2628
DRS_RESOLVER_URL = MARTHA_URL
2729
else:
2830
DRS_RESOLVER_URL = DRSHUB_URL
31+
32+
33+
class ExecutionEnvironment(Enum):
34+
TERRA_WORKSPACE = 1, # Executing in a Terra Workspace (on any supported platform)
35+
OTHER = 2 # Executing outside of a Terra Workspace (e.g., local system)
36+
37+
38+
class ExecutionPlatform(Enum):
39+
AZURE = 1, # Executing in an Azure compute environment
40+
GOOGLE = 2, # Executing in a Google compute environment
41+
UNKNOWN = 3 # Execution platform not identified (e.g., local system)
42+
43+
44+
@dataclass
45+
class ExecutionContext:
46+
execution_environment: ExecutionEnvironment
47+
execution_platform: ExecutionPlatform

terra_notebook_utils/azure_auth.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""
2+
Microsoft Azure specific code.
3+
Currently limited to Azure auth.
4+
5+
See:
6+
https://azuresdkdocs.blob.core.windows.net/$web/python/azure-identity/1.12.0/index.html
7+
https://learn.microsoft.com/en-us/python/api/azure-identity/azure.identity.defaultazurecredential?view=azure-python
8+
https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/identity/azure-identity/azure/identity/_credentials/default.py
9+
"""
10+
11+
import os
12+
from typing import Optional
13+
14+
from azure.identity import DefaultAzureCredential
15+
from terra_notebook_utils.logger import logger
16+
17+
18+
# Single instance of DefaultAzureCredential that initialized lazily.
19+
# The instance is treated as threadsafe and reusable.
20+
# The Azure documentation is silent on thread safety.
21+
# Based on scanning the code, it appears to be threadsafe.
22+
# See: https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/identity/
23+
# azure-identity/azure/identity/_credentials/default.py
24+
_AZURE_CREDENTIAL: Optional[DefaultAzureCredential] = None
25+
26+
27+
def _get_default_credential() -> DefaultAzureCredential:
28+
"""
29+
Instantiate DefaultAzureCredential lazily if/when needed.
30+
31+
Note: It would not need to be instantiated this way, as
32+
# no exception is raised even if Azure credentials are not configured.
33+
:return: Reference to instance of DefaultAzureCredential
34+
"""
35+
36+
# Should a more sophisticated Singleton pattern be used instead?
37+
global _AZURE_CREDENTIAL
38+
if not _AZURE_CREDENTIAL:
39+
_AZURE_CREDENTIAL = DefaultAzureCredential()
40+
return _AZURE_CREDENTIAL
41+
42+
43+
def get_azure_access_token() -> str:
44+
"""
45+
Return an Azure access token.
46+
47+
raises ClientAuthenticationError
48+
"""
49+
if os.environ.get('TERRA_NOTEBOOK_AZURE_ACCESS_TOKEN'):
50+
logger.debug("Using Azure token configured using 'TERRA_NOTEBOOK_AZURE_ACCESS_TOKEN'")
51+
token = os.environ['TERRA_NOTEBOOK_AZURE_ACCESS_TOKEN']
52+
else:
53+
logger.debug("Requesting Azure default credentials token.")
54+
token_scope = "https://management.azure.com/.default"
55+
azure_token = _get_default_credential().get_token(token_scope)
56+
logger.debug("Using Azure default credentials token.")
57+
token = azure_token.token
58+
return token

terra_notebook_utils/drs.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@
1111
WORKSPACE_GOOGLE_PROJECT
1212
from terra_notebook_utils import workspace, gs, tar_gz, TERRA_DEPLOYMENT_ENV, _GS_SCHEMA
1313
from terra_notebook_utils.utils import is_notebook
14-
from terra_notebook_utils.http import http
14+
from terra_notebook_utils.http_utils import http
1515
from terra_notebook_utils.blobstore.gs import GSBlob
1616
from terra_notebook_utils.blobstore.local import LocalBlob
1717
from terra_notebook_utils.blobstore.url import URLBlob
1818
from terra_notebook_utils.blobstore.progress import Indicator
1919
from terra_notebook_utils.blobstore import Blob, copy_client, BlobNotFoundError
2020
from terra_notebook_utils.logger import logger
21+
from terra_notebook_utils.terra_auth import get_terra_access_token
2122

2223

2324
DRSInfo = namedtuple("DRSInfo", "credentials access_url bucket_name key name size updated checksums")
@@ -46,7 +47,7 @@ def enable_requester_pays(workspace_name: Optional[str]=WORKSPACE_NAME,
4647
rawls_url = (f"https://rawls.dsde-{TERRA_DEPLOYMENT_ENV}.broadinstitute.org/api/workspaces/"
4748
f"{workspace_namespace}/{encoded_workspace}/enableRequesterPaysForLinkedServiceAccounts")
4849
logger.info("Enabling requester pays for your workspace. This will only take a few seconds...")
49-
access_token = gs.get_access_token()
50+
access_token = get_terra_access_token()
5051

5152
headers = {
5253
'authorization': f"Bearer {access_token}",
@@ -61,7 +62,7 @@ def enable_requester_pays(workspace_name: Optional[str]=WORKSPACE_NAME,
6162

6263
def get_drs(drs_url: str, fields: List[str]) -> Response:
6364
"""Request DRS information from DRS Resolver."""
64-
access_token = gs.get_access_token()
65+
access_token = get_terra_access_token()
6566

6667
headers = {
6768
'authorization': f"Bearer {access_token}",

terra_notebook_utils/gs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def get_access_token():
4242
return token
4343

4444
def reset_bond_cache():
45-
from terra_notebook_utils.http import http
45+
from terra_notebook_utils.http_utils import http
4646
token = get_access_token()
4747
headers = {
4848
'authorization': f'Bearer {token}',
File renamed without changes.

terra_notebook_utils/logger.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import json
21
import logging
2+
from logging import Logger
33

4-
logger = logging.getLogger(__name__)
4+
logger: Logger = logging.getLogger(__name__)

terra_notebook_utils/table.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import requests
99

10-
from terra_notebook_utils.http import Retry, http_session
10+
from terra_notebook_utils.http_utils import Retry, http_session
1111
from terra_notebook_utils.utils import _AsyncContextManager
1212
from terra_notebook_utils import WORKSPACE_NAME, WORKSPACE_NAMESPACE
1313

terra_notebook_utils/terra_auth.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""
2+
Support for auth with Terra backend services.
3+
"""
4+
from azure.core.exceptions import ClientAuthenticationError
5+
from google.auth.exceptions import DefaultCredentialsError
6+
7+
from terra_notebook_utils import azure_auth, gs, ExecutionPlatform
8+
from terra_notebook_utils.logger import logger
9+
from terra_notebook_utils.utils import get_execution_context
10+
11+
12+
class AuthenticationError(Exception):
13+
pass
14+
15+
16+
class TerraAuthTokenProvider:
17+
"""
18+
Provides auth bearer tokens suitable for use with Terra backend services.
19+
"""
20+
def __init__(self):
21+
self.execution_context = get_execution_context()
22+
23+
@staticmethod
24+
def _identify_valid_access_token() -> str:
25+
"""
26+
Try to obtain an auth bearer tokens suitable for use with Terra backend services
27+
from the Terra supported auth providers. First try Google, then try Azure.
28+
Return the first successfully obtained token, otherwise raise AuthenticationError.
29+
30+
:return: auth bearer token suitable for use with Terra backend services
31+
:raises: AuthenticationError
32+
"""
33+
try:
34+
logger.debug("Attempting to obtain a Google access token to use with Terra backend services.")
35+
google_token = gs.get_access_token()
36+
logger.debug("Using Google access token to use with Terra backend services.")
37+
return google_token
38+
except DefaultCredentialsError as ex:
39+
logger.debug("Failed to obtain a Google access token to use with Terra backend services.", exc_info=ex)
40+
41+
try:
42+
logger.debug("Attempting to obtain a Azure access token to use with Terra backend services.")
43+
azure_token = azure_auth.get_azure_access_token()
44+
logger.debug("Using Azure access token to use with Terra backend services.")
45+
return azure_token
46+
except ClientAuthenticationError as ex:
47+
logger.debug("Failed to obtain a Azure access token to use with Terra backend services.", exc_info=ex)
48+
49+
raise AuthenticationError("Failed to obtain a Google or Azure token to auth with Terra backend services.")
50+
51+
def get_terra_access_token(self) -> str:
52+
if self.execution_context.execution_platform == ExecutionPlatform.GOOGLE:
53+
logger.debug("Using Google default credentials to auth with Terra services.")
54+
return gs.get_access_token()
55+
elif self.execution_context.execution_platform == ExecutionPlatform.AZURE:
56+
logger.debug("Using Azure default credentials to auth with Terra services.")
57+
return azure_auth.get_azure_access_token()
58+
else:
59+
return self._identify_valid_access_token()
60+
61+
62+
# Single instance of TerraAuthTokenProvider.
63+
TERRA_AUTH_TOKEN_PROVIDER = TerraAuthTokenProvider()
64+
65+
66+
def get_terra_access_token() -> str:
67+
""" Return an auth bearer token suitable for use with Terra backend services.
68+
:raises: AuthenticationError
69+
"""
70+
return TERRA_AUTH_TOKEN_PROVIDER.get_terra_access_token()

terra_notebook_utils/utils.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import json
2+
import os
23
import threading
34
from functools import lru_cache
45
from concurrent.futures import ThreadPoolExecutor, Future, as_completed
56
from typing import Any, Callable, Dict, Optional, Iterable, Set
67

78
import jmespath
89

10+
from terra_notebook_utils import ExecutionEnvironment, ExecutionPlatform, ExecutionContext
11+
912

1013
class _AsyncContextManager:
1114
"""Context manager for asynchronous execution. Wait on exit for all jobs to complete."""
@@ -63,3 +66,22 @@ def is_notebook() -> bool:
6366
return "ZMQInteractiveShell" == get_ipython().__class__.__name__ # type: ignore
6467
except NameError:
6568
return False
69+
70+
71+
@lru_cache()
72+
def get_execution_context() -> ExecutionContext:
73+
"""
74+
Identify information about the context in which terra-notebook-utils is executing.
75+
Currently, this determination is made based on the presence and value of the WORKSPACE_BUCKET.
76+
Other/improved means may be identified in the future.
77+
"""
78+
execution_environment = ExecutionEnvironment.OTHER
79+
execution_platform = ExecutionPlatform.UNKNOWN
80+
workspace_bucket = os.environ.get('WORKSPACE_BUCKET', None)
81+
if workspace_bucket:
82+
execution_environment = ExecutionEnvironment.TERRA_WORKSPACE
83+
if workspace_bucket.startswith("gs://"):
84+
execution_platform = ExecutionPlatform.GOOGLE
85+
elif workspace_bucket.startswith("https://"):
86+
execution_platform = ExecutionPlatform.AZURE
87+
return ExecutionContext(execution_environment, execution_platform)

0 commit comments

Comments
 (0)