Skip to content

Commit 744806c

Browse files
cemateiaDominikMe
andauthored
[OPS Common SDK] Update Communication Common SDK for Teams Phone Extensibility GA (#41219)
* Update models.py * Update models.py * Update models.py * Update phone number identifier * add tests * small updates * Update test_identifier_raw_id.py * Update test_identifier_raw_id.py * Update test_identifier_raw_id.py * Create entra_user_credential_async.py * add entra token cred * add async exchange * Update models.py * Update test_identifier_raw_id.py * Update test_identifier_raw_id.py * update credential * Update entra_user_credential_async.py * Update entra_user_credential_async.py * Update entra_user_credential_async.py * Update entra_user_credential_async.py * Create pipeline_utils.py * Create entra_token_guard_policy.py * Create entra_token_credential_options.py * Create token_exchange.py * Update token_exchange.py * updates * Update token_exchange.py * Update token_exchange.py * cleanup old files * Update token_exchange.py * Update entra_token_guard_policy.py * updated credential classes * updates * Delete manual_test.py * Update user_credential.py * Copy to all _shared in all sdks * Update sdk/communication/azure-communication-callautomation/azure/communication/callautomation/_shared/entra_token_guard_policy.py Co-authored-by: Dominik <dominik.messinger@gmail.com> * Update token_exchange.py * update version * Fix imports * Fix imports in all folders * Fix test * Update test_identifier_raw_id.py * Update dev_requirements.txt * try to fix conflict in pipeline * try to fix conflict in pipeline 2 * Update setup.py * try to fix call automation * remove references * expose the identifiers * Update documentation for credential to include the optional params * Delete manual_test.py * Update _version.py * fix comment to default the cloud * fix comments for phone number properties * fix path * Update CHANGELOG.md * fix cloud param * fix comment * Create test_entra_token_guard_policy.py * fix for cloud param * Add tests for token exchange * add tests for credential * update test * Update test_user_credential_async.py * add check in async cred * fix cloud * update not required * Update test_identifier_raw_id.py * fix passing scopes * copy paste from identity * fix space * fix sending scopes * Update test_token_exchange.py * fix spaces for doc * fix pylint errors * fix pylint error in model * fix props in phone number * fix dateutil import * update init of asserted_id * replace none with "" * update models phone number * fix some pylint errors: format * split files * pylint fixes * pylint call automation * pylint for all * fix some pylint warnings * fix last warnings in identity * update all * update token exchange * update credential for all sdks * fix comments * add some tests * fix tests * fix some comments * fix comments * copy in all sdks * fix include * fix import * fix tests * fix comments * fix analysis warnings * fix mypy * fix pylint * fix mypy * fix optional scopes --------- Co-authored-by: Dominik <dominik.messinger@gmail.com>
1 parent 61a6d33 commit 744806c

File tree

94 files changed

+6167
-342
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

94 files changed

+6167
-342
lines changed

sdk/communication/azure-communication-callautomation/azure/communication/callautomation/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
CommunicationIdentifierKind,
3939
CommunicationCloudEnvironment,
4040
UnknownIdentifier,
41+
TeamsExtensionUserProperties,
42+
TeamsExtensionUserIdentifier,
4143
)
4244
from ._generated.models._enums import (
4345
CallRejectReason,
@@ -93,6 +95,8 @@
9395
"CommunicationIdentifierKind",
9496
"CommunicationCloudEnvironment",
9597
"UnknownIdentifier",
98+
"TeamsExtensionUserProperties",
99+
"TeamsExtensionUserIdentifier",
96100

97101
# enums
98102
"CallRejectReason",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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+
7+
from azure.core.pipeline.policies import HTTPPolicy
8+
from azure.core.pipeline import PipelineRequest
9+
from . import token_utils
10+
11+
12+
class EntraTokenGuardPolicy(HTTPPolicy):
13+
"""A pipeline policy that caches the response for a given Entra token and reuses it if valid."""
14+
15+
def __init__(self):
16+
super().__init__()
17+
self._entra_token_cache = None
18+
self._response_cache = None
19+
20+
def send(self, request: PipelineRequest):
21+
cache_valid, token = token_utils.is_entra_token_cache_valid(self._entra_token_cache, request)
22+
if cache_valid and token_utils.is_acs_token_cache_valid(self._response_cache):
23+
response = self._response_cache
24+
else:
25+
self._entra_token_cache = token
26+
response = self.next.send(request)
27+
self._response_cache = response
28+
if response is None:
29+
raise RuntimeError("Failed to obtain a valid PipelineResponse in EntraTokenGuardPolicy.send")
30+
return response
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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+
7+
from azure.core.pipeline.policies import AsyncHTTPPolicy
8+
from azure.core.pipeline import PipelineRequest
9+
from . import token_utils
10+
11+
12+
class EntraTokenGuardPolicy(AsyncHTTPPolicy):
13+
"""Async pipeline policy that caches the response for a given Entra token and reuses it if valid."""
14+
15+
def __init__(self):
16+
super().__init__()
17+
self._entra_token_cache = None
18+
self._response_cache = None
19+
20+
async def send(self, request: PipelineRequest):
21+
cache_valid, token = token_utils.is_entra_token_cache_valid(self._entra_token_cache, request)
22+
if cache_valid and token_utils.is_acs_token_cache_valid(self._response_cache):
23+
response = self._response_cache
24+
else:
25+
self._entra_token_cache = token
26+
response = await self.next.send(request)
27+
self._response_cache = response
28+
if response is None:
29+
raise RuntimeError("Failed to obtain a valid PipelineResponse in AsyncEntraTokenGuardPolicy.send")
30+
return response

sdk/communication/azure-communication-callautomation/azure/communication/callautomation/_shared/models.py

Lines changed: 113 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from enum import Enum
66
import warnings
77
from typing import Mapping, Optional, Union, Any, cast
8-
from typing_extensions import Literal, TypedDict, Protocol, runtime_checkable
8+
from typing_extensions import Literal, TypedDict, Protocol, runtime_checkable, NotRequired
99

1010
from azure.core import CaseInsensitiveEnumMeta
1111

@@ -37,6 +37,7 @@ class CommunicationIdentifierKind(str, Enum, metaclass=DeprecatedEnumMeta):
3737
PHONE_NUMBER = "phone_number"
3838
MICROSOFT_TEAMS_USER = "microsoft_teams_user"
3939
MICROSOFT_TEAMS_APP = "microsoft_teams_app"
40+
TEAMS_EXTENSION_USER = "teams_extension_user"
4041

4142

4243
class CommunicationCloudEnvironment(str, Enum, metaclass=CaseInsensitiveEnumMeta):
@@ -86,6 +87,8 @@ def properties(self) -> Mapping[str, Any]:
8687
ACS_USER_GCCH_CLOUD_PREFIX = "8:gcch-acs:"
8788
SPOOL_USER_PREFIX = "8:spool:"
8889

90+
PHONE_NUMBER_ANONYMOUS_SUFFIX = "anonymous"
91+
8992

9093
class CommunicationUserProperties(TypedDict):
9194
"""Dictionary of properties for a CommunicationUserIdentifier."""
@@ -127,6 +130,10 @@ class PhoneNumberProperties(TypedDict):
127130

128131
value: str
129132
"""The phone number in E.164 format."""
133+
asserted_id: NotRequired[str]
134+
"""The asserted Id set on a phone number to distinguish from other connections made through the same number."""
135+
is_anonymous: NotRequired[bool]
136+
"""True if the phone number is anonymous, e.g. when used to represent a hidden caller Id."""
130137

131138

132139
class PhoneNumberIdentifier:
@@ -145,8 +152,21 @@ def __init__(self, value: str, **kwargs: Any) -> None:
145152
:keyword str raw_id: The raw ID of the identifier. If not specified, this will be constructed from
146153
the 'value' parameter.
147154
"""
148-
self.properties = PhoneNumberProperties(value=value)
155+
149156
raw_id: Optional[str] = kwargs.get("raw_id")
157+
is_anonymous: bool
158+
159+
if raw_id is not None:
160+
phone_number = raw_id[len(PHONE_NUMBER_PREFIX):]
161+
is_anonymous = phone_number == PHONE_NUMBER_ANONYMOUS_SUFFIX
162+
asserted_id_index = -1 if is_anonymous else phone_number.rfind("_") + 1
163+
has_asserted_id = 0 < asserted_id_index < len(phone_number)
164+
props = {"value": value, "is_anonymous": is_anonymous}
165+
if has_asserted_id:
166+
props["asserted_id"] = phone_number[asserted_id_index:]
167+
self.properties = PhoneNumberProperties(**props) # type: ignore
168+
else:
169+
self.properties = PhoneNumberProperties(value=value)
150170
self.raw_id = raw_id if raw_id is not None else self._format_raw_id(self.properties)
151171

152172
def __eq__(self, other):
@@ -163,7 +183,6 @@ def _format_raw_id(self, properties: PhoneNumberProperties) -> str:
163183
value = properties["value"]
164184
return f"{PHONE_NUMBER_PREFIX}{value}"
165185

166-
167186
class UnknownIdentifier:
168187
"""Represents an identifier of an unknown type.
169188
@@ -223,7 +242,7 @@ def __init__(self, user_id: str, **kwargs: Any) -> None:
223242
:param str user_id: Microsoft Teams user id.
224243
:keyword bool is_anonymous: `True` if the identifier is anonymous. Default value is `False`.
225244
:keyword cloud: Cloud environment that the user belongs to. Default value is `PUBLIC`.
226-
:paramtype cloud: str or ~azure.communication.chat.CommunicationCloudEnvironment
245+
:paramtype cloud: str or ~azure.communication.callautomation.CommunicationCloudEnvironment
227246
:keyword str raw_id: The raw ID of the identifier. If not specified, this value will be constructed from
228247
the other properties.
229248
"""
@@ -294,7 +313,7 @@ def __init__(self, app_id: str, **kwargs: Any) -> None:
294313
"""
295314
:param str app_id: Microsoft Teams application id.
296315
:keyword cloud: Cloud environment that the application belongs to. Default value is `PUBLIC`.
297-
:paramtype cloud: str or ~azure.communication.chat.CommunicationCloudEnvironment
316+
:paramtype cloud: str or ~azure.communication.callautomation.CommunicationCloudEnvironment
298317
:keyword str raw_id: The raw ID of the identifier. If not specified, this value will be constructed
299318
from the other properties.
300319
"""
@@ -338,7 +357,7 @@ def __init__(self, bot_id, **kwargs):
338357
:keyword bool is_resource_account_configured: `False` if the identifier is global.
339358
Default value is `True` for tennantzed bots.
340359
:keyword cloud: Cloud environment that the bot belongs to. Default value is `PUBLIC`.
341-
:paramtype cloud: str or ~azure.communication.chat.CommunicationCloudEnvironment
360+
:paramtype cloud: str or ~azure.communication.callautomation.CommunicationCloudEnvironment
342361
"""
343362
warnings.warn(
344363
"The MicrosoftBotIdentifier is deprecated and has been replaced by MicrosoftTeamsAppIdentifier.",
@@ -347,6 +366,88 @@ def __init__(self, bot_id, **kwargs):
347366
super().__init__(bot_id, **kwargs)
348367

349368

369+
class TeamsExtensionUserProperties(TypedDict):
370+
"""Dictionary of properties for a TeamsExtensionUserIdentifier."""
371+
372+
user_id: str
373+
"""The id of the Teams extension user."""
374+
tenant_id: str
375+
"""The tenant id associated with the user."""
376+
resource_id: str
377+
"""The Communication Services resource id."""
378+
cloud: Union[CommunicationCloudEnvironment, str]
379+
"""Cloud environment that this identifier belongs to."""
380+
381+
382+
class TeamsExtensionUserIdentifier:
383+
"""Represents an identifier for a Teams Extension user."""
384+
385+
kind: Literal[CommunicationIdentifierKind.TEAMS_EXTENSION_USER] = CommunicationIdentifierKind.TEAMS_EXTENSION_USER
386+
"""The type of identifier."""
387+
properties: TeamsExtensionUserProperties
388+
"""The properties of the identifier."""
389+
raw_id: str
390+
"""The raw ID of the identifier."""
391+
392+
def __init__(
393+
self,
394+
user_id: str,
395+
tenant_id: str,
396+
resource_id: str,
397+
**kwargs: Any
398+
) -> None:
399+
"""
400+
:param str user_id: Teams extension user id.
401+
:param str tenant_id: Tenant id associated with the user.
402+
:param str resource_id: The Communication Services resource id.
403+
:keyword cloud: Cloud environment that the user belongs to. Default value is `PUBLIC`.
404+
:paramtype cloud: str or ~azure.communication.callautomation.CommunicationCloudEnvironment
405+
:keyword str raw_id: The raw ID of the identifier.
406+
If not specified, this value will be constructed from the other properties.
407+
"""
408+
self.properties = TeamsExtensionUserProperties(
409+
user_id=user_id,
410+
tenant_id=tenant_id,
411+
resource_id=resource_id,
412+
cloud=kwargs.get("cloud") or CommunicationCloudEnvironment.PUBLIC,
413+
)
414+
raw_id: Optional[str] = kwargs.get("raw_id")
415+
self.raw_id = raw_id if raw_id is not None else self._format_raw_id(self.properties)
416+
417+
def __eq__(self, other):
418+
try:
419+
if other.raw_id:
420+
return self.raw_id == other.raw_id
421+
return self.raw_id == self._format_raw_id(other.properties)
422+
except Exception: # pylint: disable=broad-except
423+
return False
424+
425+
def _format_raw_id(self, properties: TeamsExtensionUserProperties) -> str:
426+
# The prefix depends on the cloud
427+
cloud = properties["cloud"]
428+
if cloud == CommunicationCloudEnvironment.DOD:
429+
prefix = ACS_USER_DOD_CLOUD_PREFIX
430+
elif cloud == CommunicationCloudEnvironment.GCCH:
431+
prefix = ACS_USER_GCCH_CLOUD_PREFIX
432+
else:
433+
prefix = ACS_USER_PREFIX
434+
return f"{prefix}{properties['resource_id']}_{properties['tenant_id']}_{properties['user_id']}"
435+
436+
def try_create_teams_extension_user(prefix: str, suffix: str) -> Optional[TeamsExtensionUserIdentifier]:
437+
segments = suffix.split("_")
438+
if len(segments) != 3:
439+
return None
440+
resource_id, tenant_id, user_id = segments
441+
if prefix == ACS_USER_PREFIX:
442+
cloud = CommunicationCloudEnvironment.PUBLIC
443+
elif prefix == ACS_USER_DOD_CLOUD_PREFIX:
444+
cloud = CommunicationCloudEnvironment.DOD
445+
elif prefix == ACS_USER_GCCH_CLOUD_PREFIX:
446+
cloud = CommunicationCloudEnvironment.GCCH
447+
else:
448+
raise ValueError("Invalid MRI")
449+
return TeamsExtensionUserIdentifier(user_id, tenant_id, resource_id, cloud=cloud)
450+
350451
def identifier_from_raw_id(raw_id: str) -> CommunicationIdentifier: # pylint: disable=too-many-return-statements
351452
"""
352453
Creates a CommunicationIdentifier from a given raw ID.
@@ -407,11 +508,16 @@ def identifier_from_raw_id(raw_id: str) -> CommunicationIdentifier: # pylint: d
407508
cloud=CommunicationCloudEnvironment.GCCH,
408509
raw_id=raw_id,
409510
)
511+
if prefix == SPOOL_USER_PREFIX:
512+
return CommunicationUserIdentifier(id=raw_id, raw_id=raw_id)
513+
410514
if prefix in [
411515
ACS_USER_PREFIX,
412516
ACS_USER_DOD_CLOUD_PREFIX,
413517
ACS_USER_GCCH_CLOUD_PREFIX,
414-
SPOOL_USER_PREFIX,
415518
]:
519+
identifier = try_create_teams_extension_user(prefix, suffix)
520+
if identifier is not None:
521+
return identifier
416522
return CommunicationUserIdentifier(id=raw_id, raw_id=raw_id)
417523
return UnknownIdentifier(identifier=raw_id)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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+
7+
import json
8+
from typing import Any, List, Optional
9+
# pylint: disable=non-abstract-transport-import
10+
# pylint: disable=no-name-in-module
11+
from azure.core.pipeline.transport import RequestsTransport
12+
from azure.core.credentials import AccessToken
13+
from azure.core.pipeline import Pipeline, PipelineResponse
14+
from azure.core.pipeline.policies import BearerTokenCredentialPolicy
15+
from azure.core.exceptions import HttpResponseError
16+
from azure.core.credentials import TokenCredential
17+
from azure.core.pipeline.policies import RetryPolicy
18+
from .entra_token_guard_policy import EntraTokenGuardPolicy
19+
from . import token_utils
20+
21+
22+
class TokenExchangeClient:
23+
"""Represents a client that exchanges an Entra token for an Azure Communication Services (ACS) token.
24+
25+
:param resource_endpoint: The endpoint URL of the resource to authenticate against.
26+
:param credential: The credential to use for token exchange.
27+
:param scopes: The scopes to request during the token exchange.
28+
:keyword transport: Optional transport to use for the pipeline.
29+
"""
30+
31+
# pylint: disable=C4748
32+
# pylint: disable=client-method-missing-type-annotations
33+
def __init__(
34+
self,
35+
resource_endpoint: str,
36+
credential: TokenCredential,
37+
scopes: Optional[List[str]] = None,
38+
**kwargs: Any):
39+
40+
self._resource_endpoint = resource_endpoint
41+
self._scopes = scopes or ["https://communication.azure.com/clients/.default"]
42+
self._credential = credential
43+
pipeline_transport = kwargs.get("transport", None)
44+
self._pipeline = self._create_pipeline_from_options(pipeline_transport)
45+
46+
def _create_pipeline_from_options(self, pipeline_transport):
47+
auth_policy = BearerTokenCredentialPolicy(self._credential, *self._scopes)
48+
entra_token_guard_policy = EntraTokenGuardPolicy()
49+
retry_policy = RetryPolicy()
50+
policies = [auth_policy, entra_token_guard_policy, retry_policy]
51+
if pipeline_transport:
52+
return Pipeline(policies=policies, transport=pipeline_transport)
53+
return Pipeline(policies=policies, transport=RequestsTransport())
54+
55+
def exchange_entra_token(self) -> AccessToken:
56+
message = token_utils.create_request_message(self._resource_endpoint, self._scopes)
57+
response = self._pipeline.run(message)
58+
return self._parse_access_token_from_response(response)
59+
60+
def _parse_access_token_from_response(self, response: PipelineResponse) -> AccessToken:
61+
if response.http_response.status_code == 200:
62+
try:
63+
content = response.http_response.text()
64+
data = json.loads(content)
65+
access_token_json = data["accessToken"]
66+
token = access_token_json["token"]
67+
expires_on = access_token_json["expiresOn"]
68+
expires_on_epoch = token_utils.parse_expires_on(expires_on, response)
69+
if expires_on_epoch is None:
70+
raise ValueError("Failed to parse 'expiresOn' value from access token response")
71+
return AccessToken(token, expires_on_epoch)
72+
except Exception as ex:
73+
raise ValueError("Failed to parse access token from response") from ex
74+
else:
75+
raise HttpResponseError(
76+
message="Failed to exchange Entra token for ACS token",
77+
response=response.http_response
78+
)

0 commit comments

Comments
 (0)