Skip to content

Commit 5b928c1

Browse files
authored
[Cosmos] AAD authentication sync client (Azure#23604)
* working authentication to get database account * working aad authentication for sync client with sample * readme and changelog * pylint and better comments on sample * Update auth.py * Revert "Update auth.py" This reverts commit 721bbc7. * Update auth.py * Update auth.py * changes from comments * quick comment updates * Update config.py * Update access_cosmos_with_aad.py * added sync policy to match async * small changes * aad tests for negative path and positive emulator path * moved logic to be together for each part * Milis comments * Update cosmos_client.py * Update dev_requirements.txt * Update _auth_policy.py
1 parent bec589a commit 5b928c1

File tree

12 files changed

+488
-24
lines changed

12 files changed

+488
-24
lines changed

sdk/cosmos/azure-cosmos/CHANGELOG.md

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,7 @@
33
### 4.3.0b4 (Unreleased)
44

55
#### Features Added
6-
7-
#### Breaking Changes
8-
9-
#### Bugs Fixed
10-
11-
#### Other Changes
6+
- Added support for AAD authentication for the sync client
127

138
### 4.3.0b3 (2022-03-10)
149

sdk/cosmos/azure-cosmos/README.md

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,35 @@ KEY = os.environ['ACCOUNT_KEY']
7676
client = CosmosClient(URL, credential=KEY)
7777
```
7878

79+
### AAD Authentication
80+
81+
You can also authenticate a client utilizing your service principal's AAD credentials and the azure identity package.
82+
You can directly pass in the credentials information to ClientSecretCrednetial, or use the DefaultAzureCredential:
83+
```Python
84+
from azure.cosmos import CosmosClient
85+
from azure.identity import ClientSecretCredential, DefaultAzureCredential
86+
87+
import os
88+
url = os.environ['ACCOUNT_URI']
89+
tenant_id = os.environ['TENANT_ID']
90+
client_id = os.environ['CLIENT_ID']
91+
client_secret = os.environ['CLIENT_SECRET']
92+
93+
# Using ClientSecretCredential
94+
aad_credentials = ClientSecretCredential(
95+
tenant_id=tenant_id,
96+
client_id=client_id,
97+
client_secret=client_secret)
98+
99+
# Using DefaultAzureCredential (recommended)
100+
aad_credentials = DefaultAzureCredential()
101+
102+
client = CosmosClient(url, aad_credentials)
103+
```
104+
Always ensure that the managed identity you use for AAD authentication has `readMetadata` permissions. <br>
105+
More information on how to set up AAD authentication: [Set up RBAC for AAD authentication](https://docs.microsoft.com/azure/cosmos-db/how-to-setup-rbac) <br>
106+
More information on allowed operations for AAD authenticated clients: [RBAC Permission Model](https://aka.ms/cosmos-native-rbac)
107+
79108
## Key concepts
80109

81110
Once you've initialized a [CosmosClient][ref_cosmosclient], you can interact with the primary resource types in Cosmos DB:
@@ -125,7 +154,7 @@ Currently the features below are **not supported**. For alternatives options, ch
125154
* Change Feed: Processor
126155
* Change Feed: Read multiple partitions key values
127156
* Change Feed: Read specific time
128-
* Change Feed: Read from the beggining
157+
* Change Feed: Read from the beginning
129158
* Change Feed: Pull model
130159
* Cross-partition ORDER BY for mixed types
131160

@@ -139,10 +168,6 @@ Currently the features below are **not supported**. For alternatives options, ch
139168
* Get the connection string
140169
* Get the minimum RU/s of a container
141170

142-
### Security Limitations:
143-
144-
* AAD support
145-
146171
## Workarounds
147172

148173
### Bulk processing Limitation Workaround
@@ -153,10 +178,6 @@ If you want to use Python SDK to perform bulk inserts to Cosmos DB, the best alt
153178

154179
Typically, you can use [Azure Portal](https://portal.azure.com/), [Azure Cosmos DB Resource Provider REST API](https://docs.microsoft.com/rest/api/cosmos-db-resource-provider), [Azure CLI](https://docs.microsoft.com/cli/azure/azure-cli-reference-for-cosmos-db) or [PowerShell](https://docs.microsoft.com/azure/cosmos-db/manage-with-powershell) for the control plane unsupported limitations.
155180

156-
### AAD Support Workaround
157-
158-
A possible workaround is to use managed identities to [programmatically](https://docs.microsoft.com/azure/cosmos-db/managed-identity-based-authentication) get the keys.
159-
160181
## Boolean Data Type
161182

162183
While the Python language [uses](https://docs.python.org/3/library/stdtypes.html?highlight=boolean#truth-value-testing) "True" and "False" for boolean types, Cosmos DB [accepts](https://docs.microsoft.com/azure/cosmos-db/sql-query-is-bool) "true" and "false" only. In other words, the Python language uses Boolean values with the first uppercase letter and all other lowercase letters, while Cosmos DB and its SQL language use only lowercase letters for those same Boolean values. How to deal with this challenge?
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# -------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See LICENSE.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
import time
7+
8+
from typing import Any, Dict, Optional
9+
from azure.core.credentials import AccessToken
10+
from azure.core.pipeline import PipelineRequest, PipelineResponse
11+
from azure.core.pipeline.policies import HTTPPolicy
12+
from azure.cosmos import http_constants
13+
14+
15+
# pylint:disable=too-few-public-methods
16+
class _CosmosBearerTokenCredentialPolicyBase(object):
17+
"""Base class for a Bearer Token Credential Policy.
18+
19+
:param credential: The credential.
20+
:type credential: ~azure.core.credentials.TokenCredential
21+
:param str scopes: Lets you specify the type of access needed.
22+
"""
23+
24+
def __init__(self, credential, *scopes, **kwargs): # pylint:disable=unused-argument
25+
# type: (TokenCredential, *str, **Any) -> None
26+
super(_CosmosBearerTokenCredentialPolicyBase, self).__init__()
27+
self._scopes = scopes
28+
self._credential = credential
29+
self._token = None # type: Optional[AccessToken]
30+
31+
@staticmethod
32+
def _enforce_https(request):
33+
# type: (PipelineRequest) -> None
34+
35+
# move 'enforce_https' from options to context so it persists
36+
# across retries but isn't passed to a transport implementation
37+
option = request.context.options.pop("enforce_https", None)
38+
39+
# True is the default setting; we needn't preserve an explicit opt in to the default behavior
40+
if option is False:
41+
request.context["enforce_https"] = option
42+
43+
enforce_https = request.context.get("enforce_https", True)
44+
if enforce_https and not request.http_request.url.lower().startswith("https"):
45+
raise ValueError(
46+
"Bearer token authentication is not permitted for non-TLS protected (non-https) URLs."
47+
)
48+
49+
@staticmethod
50+
def _update_headers(headers, token):
51+
# type: (Dict[str, str], str) -> None
52+
"""Updates the Authorization header with the bearer token.
53+
This is the main method that differentiates this policy from core's BearerTokenCredentialPolicy and works
54+
to properly sign the authorization header for Cosmos' REST API. For more information:
55+
https://docs.microsoft.com/rest/api/cosmos-db/access-control-on-cosmosdb-resources#authorization-header
56+
57+
:param dict headers: The HTTP Request headers
58+
:param str token: The OAuth token.
59+
"""
60+
headers[http_constants.HttpHeaders.Authorization] = "type=aad&ver=1.0&sig={}".format(token)
61+
62+
@property
63+
def _need_new_token(self):
64+
# type: () -> bool
65+
return not self._token or self._token.expires_on - time.time() < 300
66+
67+
68+
class CosmosBearerTokenCredentialPolicy(_CosmosBearerTokenCredentialPolicyBase, HTTPPolicy):
69+
"""Adds a bearer token Authorization header to requests.
70+
71+
:param credential: The credential.
72+
:type credential: ~azure.core.TokenCredential
73+
:param str scopes: Lets you specify the type of access needed.
74+
:raises ValueError: If https_enforce does not match with endpoint being used.
75+
"""
76+
77+
def on_request(self, request):
78+
# type: (PipelineRequest) -> None
79+
"""Called before the policy sends a request.
80+
81+
The base implementation authorizes the request with a bearer token.
82+
83+
:param ~azure.core.pipeline.PipelineRequest request: the request
84+
"""
85+
self._enforce_https(request)
86+
87+
if self._token is None or self._need_new_token:
88+
self._token = self._credential.get_token(*self._scopes)
89+
self._update_headers(request.http_request.headers, self._token.token)
90+
91+
def authorize_request(self, request, *scopes, **kwargs):
92+
# type: (PipelineRequest, *str, **Any) -> None
93+
"""Acquire a token from the credential and authorize the request with it.
94+
95+
Keyword arguments are passed to the credential's get_token method. The token will be cached and used to
96+
authorize future requests.
97+
98+
:param ~azure.core.pipeline.PipelineRequest request: the request
99+
:param str scopes: required scopes of authentication
100+
"""
101+
self._token = self._credential.get_token(*scopes, **kwargs)
102+
self._update_headers(request.http_request.headers, self._token.token)
103+
104+
def send(self, request):
105+
# type: (PipelineRequest) -> PipelineResponse
106+
"""Authorize request with a bearer token and send it to the next policy
107+
108+
:param request: The pipeline request object
109+
:type request: ~azure.core.pipeline.PipelineRequest
110+
"""
111+
self.on_request(request)
112+
try:
113+
response = self.next.send(request)
114+
self.on_response(request, response)
115+
except Exception: # pylint:disable=broad-except
116+
self.on_exception(request)
117+
raise
118+
else:
119+
if response.http_response.status_code == 401:
120+
self._token = None # any cached token is invalid
121+
if "WWW-Authenticate" in response.http_response.headers:
122+
request_authorized = self.on_challenge(request, response)
123+
if request_authorized:
124+
try:
125+
response = self.next.send(request)
126+
self.on_response(request, response)
127+
except Exception: # pylint:disable=broad-except
128+
self.on_exception(request)
129+
raise
130+
131+
return response
132+
133+
def on_challenge(self, request, response):
134+
# type: (PipelineRequest, PipelineResponse) -> bool
135+
"""Authorize request according to an authentication challenge
136+
137+
This method is called when the resource provider responds 401 with a WWW-Authenticate header.
138+
139+
:param ~azure.core.pipeline.PipelineRequest request: the request which elicited an authentication challenge
140+
:param ~azure.core.pipeline.PipelineResponse response: the resource provider's response
141+
:returns: a bool indicating whether the policy should send the request
142+
"""
143+
# pylint:disable=unused-argument,no-self-use
144+
return False
145+
146+
def on_response(self, request, response):
147+
# type: (PipelineRequest, PipelineResponse) -> None
148+
"""Executed after the request comes back from the next policy.
149+
150+
:param request: Request to be modified after returning from the policy.
151+
:type request: ~azure.core.pipeline.PipelineRequest
152+
:param response: Pipeline response object
153+
:type response: ~azure.core.pipeline.PipelineResponse
154+
"""
155+
156+
def on_exception(self, request):
157+
# type: (PipelineRequest) -> None
158+
"""Executed when an exception is raised while executing the next policy.
159+
160+
This method is executed inside the exception handler.
161+
162+
:param request: The Pipeline request object
163+
:type request: ~azure.core.pipeline.PipelineRequest
164+
"""
165+
# pylint: disable=no-self-use,unused-argument
166+
return

sdk/cosmos/azure-cosmos/azure/cosmos/_base.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from typing import Dict, Any
3131

3232
from urllib.parse import quote as urllib_quote
33+
from urllib.parse import urlsplit
3334

3435
from azure.core import MatchConditions
3536

@@ -663,6 +664,11 @@ def ParsePaths(paths):
663664
return tokens
664665

665666

667+
def create_scope_from_url(url):
668+
parsed_url = urlsplit(url)
669+
return parsed_url.scheme + "://" + parsed_url.hostname + "/.default"
670+
671+
666672
def validate_cache_staleness_value(max_integrated_cache_staleness):
667673
int(max_integrated_cache_staleness) # Will throw error if data type cant be converted to int
668674
if max_integrated_cache_staleness <= 0:

sdk/cosmos/azure-cosmos/azure/cosmos/_cosmos_client_connection.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
from . import _session
5959
from . import _utils
6060
from .partition_key import _Undefined, _Empty
61+
from ._auth_policy import CosmosBearerTokenCredentialPolicy
6162

6263
ClassType = TypeVar("ClassType")
6364

@@ -116,9 +117,11 @@ def __init__(
116117

117118
self.master_key = None
118119
self.resource_tokens = None
120+
self.aad_credentials = None
119121
if auth is not None:
120122
self.master_key = auth.get("masterKey")
121123
self.resource_tokens = auth.get("resourceTokens")
124+
self.aad_credentials = auth.get("clientSecretCredential")
122125

123126
if auth.get("permissionFeed"):
124127
self.resource_tokens = {}
@@ -176,12 +179,18 @@ def __init__(
176179

177180
self._user_agent = _utils.get_user_agent()
178181

182+
credentials_policy = None
183+
if self.aad_credentials:
184+
scopes = base.create_scope_from_url(self.url_connection)
185+
credentials_policy = CosmosBearerTokenCredentialPolicy(self.aad_credentials, scopes)
186+
179187
policies = [
180188
HeadersPolicy(**kwargs),
181189
ProxyPolicy(proxies=proxies),
182190
UserAgentPolicy(base_user_agent=self._user_agent, **kwargs),
183191
ContentDecodePolicy(),
184192
retry_policy,
193+
credentials_policy,
185194
CustomHookPolicy(**kwargs),
186195
NetworkTraceLoggingPolicy(**kwargs),
187196
DistributedTracingPolicy(**kwargs),

sdk/cosmos/azure-cosmos/azure/cosmos/auth.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,11 @@
2626
from hashlib import sha256
2727
import hmac
2828
import urllib.parse
29-
3029
from . import http_constants
3130

3231

3332
def GetAuthorizationHeader(
34-
cosmos_client_connection, verb, path, resource_id_or_fullname, is_name_based, resource_type, headers
33+
cosmos_client_connection, verb, path, resource_id_or_fullname, is_name_based, resource_type, headers
3534
):
3635
"""Gets the authorization header.
3736
@@ -51,18 +50,18 @@ def GetAuthorizationHeader(
5150
resource_id_or_fullname = resource_id_or_fullname.lower()
5251

5352
if cosmos_client_connection.master_key:
54-
return __GetAuthorizationTokenUsingMasterKey(
53+
return __get_authorization_token_using_master_key(
5554
verb, resource_id_or_fullname, resource_type, headers, cosmos_client_connection.master_key
5655
)
5756
if cosmos_client_connection.resource_tokens:
58-
return __GetAuthorizationTokenUsingResourceTokens(
57+
return __get_authorization_token_using_resource_token(
5958
cosmos_client_connection.resource_tokens, path, resource_id_or_fullname
6059
)
6160

6261
return None
6362

6463

65-
def __GetAuthorizationTokenUsingMasterKey(verb, resource_id_or_fullname, resource_type, headers, master_key):
64+
def __get_authorization_token_using_master_key(verb, resource_id_or_fullname, resource_type, headers, master_key):
6665
"""Gets the authorization token using `master_key.
6766
6867
:param str verb:
@@ -97,7 +96,7 @@ def __GetAuthorizationTokenUsingMasterKey(verb, resource_id_or_fullname, resourc
9796
return "type={type}&ver={ver}&sig={sig}".format(type=master_token, ver=token_version, sig=signature[:-1])
9897

9998

100-
def __GetAuthorizationTokenUsingResourceTokens(resource_tokens, path, resource_id_or_fullname):
99+
def __get_authorization_token_using_resource_token(resource_tokens, path, resource_id_or_fullname):
101100
"""Get the authorization token using `resource_tokens`.
102101
103102
:param dict resource_tokens:
@@ -138,7 +137,7 @@ def __GetAuthorizationTokenUsingResourceTokens(resource_tokens, path, resource_i
138137

139138
# Get the last resource id or resource name from the path and get it's token from resource_tokens
140139
for i in range(len(path_parts), 1, -1):
141-
segment = path_parts[i-1]
140+
segment = path_parts[i - 1]
142141
sub_path = "/".join(path_parts[:i])
143142
if not segment in resource_types and sub_path in resource_tokens:
144143
return resource_tokens[sub_path]

sdk/cosmos/azure-cosmos/azure/cosmos/cosmos_client.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,13 @@ def _build_auth(credential):
6060
auth['resourceTokens'] = credential # type: ignore
6161
elif hasattr(credential, '__iter__'):
6262
auth['permissionFeed'] = credential
63+
elif hasattr(credential, 'get_token'):
64+
auth['clientSecretCredential'] = credential
6365
else:
6466
raise TypeError(
65-
"Unrecognized credential type. Please supply the master key as str, "
66-
"or a dictionary or resource tokens, or a list of permissions.")
67+
"Unrecognized credential type. Please supply the master key as a string "
68+
"or a dictionary, or resource tokens, or a list of permissions, or any instance of a class implementing"
69+
" TokenCredential (see azure.identity module for specific implementations such as ClientSecretCredential).")
6770
return auth
6871

6972

sdk/cosmos/azure-cosmos/azure/cosmos/http_constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ class SubStatusCodes(object):
379379
REDUNDANT_COLLECTION_PUT = 1009
380380
SHARED_THROUGHPUT_DATABASE_QUOTA_EXCEEDED = 1010
381381
SHARED_THROUGHPUT_OFFER_GROW_NOT_NEEDED = 1011
382+
AAD_REQUEST_NOT_AUTHORIZED = 5300
382383

383384
# 404: LSN in session token is higher
384385
READ_SESSION_NOTAVAILABLE = 1002
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
azure-core
2+
azure-identity
23
-e ../../../tools/azure-sdk-tools
34
-e ../../../tools/azure-devtools

0 commit comments

Comments
 (0)