Skip to content

Commit 323609b

Browse files
authored
[Identity] Add subscription support to Azure CLI cred (Azure#37994)
This allows users to specify a subscription when authenticating with the Azure CLI. Signed-off-by: Paul Van Eck <[email protected]>
1 parent 60e4369 commit 323609b

File tree

8 files changed

+126
-5
lines changed

8 files changed

+126
-5
lines changed

sdk/identity/azure-identity/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### Features Added
66

7+
- Added `subscription` parameter to `AzureCliCredential` to specify the subscription to use when authenticating with the Azure CLI. ([#37994](https://github.com/Azure/azure-sdk-for-python/pull/37994))
8+
79
### Breaking Changes
810

911
### Bugs Fixed

sdk/identity/azure-identity/azure/identity/_credentials/azure_cli.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@
1515
from azure.core.exceptions import ClientAuthenticationError
1616

1717
from .. import CredentialUnavailableError
18-
from .._internal import _scopes_to_resource, resolve_tenant, within_dac, validate_tenant_id, validate_scope
18+
from .._internal import (
19+
_scopes_to_resource,
20+
resolve_tenant,
21+
within_dac,
22+
validate_tenant_id,
23+
validate_scope,
24+
validate_subscription,
25+
)
1926
from .._internal.decorators import log_get_token
2027

2128

@@ -31,6 +38,8 @@ class AzureCliCredential:
3138
This requires previously logging in to Azure via "az login", and will use the CLI's currently logged in identity.
3239
3340
:keyword str tenant_id: Optional tenant to include in the token request.
41+
:keyword str subscription: The name or ID of a subscription. Set this to acquire tokens for an account other
42+
than the Azure CLI's current account.
3443
:keyword List[str] additionally_allowed_tenants: Specifies tenants in addition to the specified "tenant_id"
3544
for which the credential may acquire tokens. Add the wildcard value "*" to allow the credential to
3645
acquire tokens for any tenant the application can access.
@@ -50,12 +59,17 @@ def __init__(
5059
self,
5160
*,
5261
tenant_id: str = "",
62+
subscription: Optional[str] = None,
5363
additionally_allowed_tenants: Optional[List[str]] = None,
5464
process_timeout: int = 10,
5565
) -> None:
5666
if tenant_id:
5767
validate_tenant_id(tenant_id)
68+
if subscription:
69+
validate_subscription(subscription)
70+
5871
self.tenant_id = tenant_id
72+
self.subscription = subscription
5973
self._additionally_allowed_tenants = additionally_allowed_tenants or []
6074
self._process_timeout = process_timeout
6175

@@ -144,6 +158,9 @@ def _get_token_base(
144158
)
145159
if tenant:
146160
command += " --tenant " + tenant
161+
162+
if self.subscription:
163+
command += f' --subscription "{self.subscription}"'
147164
output = _run_command(command, self._process_timeout)
148165

149166
token = parse_token(output)

sdk/identity/azure-identity/azure/identity/_internal/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
normalize_authority,
1414
resolve_tenant,
1515
validate_scope,
16+
validate_subscription,
1617
validate_tenant_id,
1718
within_credential_chain,
1819
within_dac,
@@ -49,6 +50,7 @@ def _scopes_to_resource(*scopes) -> str:
4950
"normalize_authority",
5051
"resolve_tenant",
5152
"validate_scope",
53+
"validate_subscription",
5254
"within_credential_chain",
5355
"within_dac",
5456
"wrap_exceptions",

sdk/identity/azure-identity/azure/identity/_internal/utils.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
VALID_TENANT_ID_CHARACTERS = frozenset(ascii_letters + digits + "-.")
2222
VALID_SCOPE_CHARACTERS = frozenset(ascii_letters + digits + "_-.:/")
23+
VALID_SUBSCRIPTION_CHARACTERS = frozenset(ascii_letters + digits + "_-. ")
2324

2425

2526
def normalize_authority(authority: str) -> str:
@@ -68,7 +69,20 @@ def validate_tenant_id(tenant_id: str) -> None:
6869
if not tenant_id or any(c not in VALID_TENANT_ID_CHARACTERS for c in tenant_id):
6970
raise ValueError(
7071
"Invalid tenant ID provided. You can locate your tenant ID by following the instructions here: "
71-
+ "https://learn.microsoft.com/partner-center/find-ids-and-domain-names"
72+
"https://learn.microsoft.com/partner-center/find-ids-and-domain-names"
73+
)
74+
75+
76+
def validate_subscription(subscription: str) -> None:
77+
"""Raise ValueError if subscription is empty or contains a character invalid for a subscription name/ID.
78+
79+
:param str subscription: subscription ID to validate
80+
:raises: ValueError if subscription is empty or contains a character invalid for a subscription ID.
81+
"""
82+
if not subscription or any(c not in VALID_SUBSCRIPTION_CHARACTERS for c in subscription):
83+
raise ValueError(
84+
"Invalid subscription provided. You can locate your subscription by following the "
85+
"instructions listed here: https://learn.microsoft.com/azure/azure-portal/get-subscription-tenant-id"
7286
)
7387

7488

sdk/identity/azure-identity/azure/identity/aio/_credentials/azure_cli.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,14 @@
2323
parse_token,
2424
sanitize_output,
2525
)
26-
from ..._internal import _scopes_to_resource, resolve_tenant, within_dac, validate_tenant_id, validate_scope
26+
from ..._internal import (
27+
_scopes_to_resource,
28+
resolve_tenant,
29+
within_dac,
30+
validate_tenant_id,
31+
validate_scope,
32+
validate_subscription,
33+
)
2734

2835

2936
class AzureCliCredential(AsyncContextManager):
@@ -32,6 +39,8 @@ class AzureCliCredential(AsyncContextManager):
3239
This requires previously logging in to Azure via "az login", and will use the CLI's currently logged in identity.
3340
3441
:keyword str tenant_id: Optional tenant to include in the token request.
42+
:keyword str subscription: The name or ID of a subscription. Set this to acquire tokens for an account other
43+
than the Azure CLI's current account.
3544
:keyword List[str] additionally_allowed_tenants: Specifies tenants in addition to the specified "tenant_id"
3645
for which the credential may acquire tokens. Add the wildcard value "*" to allow the credential to
3746
acquire tokens for any tenant the application can access.
@@ -51,12 +60,17 @@ def __init__(
5160
self,
5261
*,
5362
tenant_id: str = "",
63+
subscription: Optional[str] = None,
5464
additionally_allowed_tenants: Optional[List[str]] = None,
5565
process_timeout: int = 10,
5666
) -> None:
5767
if tenant_id:
5868
validate_tenant_id(tenant_id)
69+
if subscription:
70+
validate_subscription(subscription)
71+
5972
self.tenant_id = tenant_id
73+
self.subscription = subscription
6074
self._additionally_allowed_tenants = additionally_allowed_tenants or []
6175
self._process_timeout = process_timeout
6276

@@ -141,6 +155,8 @@ async def _get_token_base(
141155

142156
if tenant:
143157
command += " --tenant " + tenant
158+
if self.subscription:
159+
command += f' --subscription "{self.subscription}"'
144160
output = await _run_command(command, self._process_timeout)
145161

146162
token = parse_token(output)

sdk/identity/azure-identity/tests/helpers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
FAKE_CLIENT_ID = "fake-client-id"
1515
INVALID_CHARACTERS = "|\\`;{&' "
16+
INVALID_SUBSCRIPTION_CHARACTERS = "|\\`;{&'"
1617
ACCESS_TOKEN_CLASSES = (AccessToken, AccessTokenInfo)
1718
GET_TOKEN_METHODS = ("get_token", "get_token_info")
1819

sdk/identity/azure-identity/tests/test_cli_credential.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import subprocess
1616
import pytest
1717

18-
from helpers import mock, INVALID_CHARACTERS, GET_TOKEN_METHODS
18+
from helpers import mock, INVALID_CHARACTERS, INVALID_SUBSCRIPTION_CHARACTERS, GET_TOKEN_METHODS
1919

2020
CHECK_OUTPUT = AzureCliCredential.__module__ + ".subprocess.check_output"
2121

@@ -76,6 +76,40 @@ def test_invalid_scopes(get_token_method):
7676
getattr(AzureCliCredential(), get_token_method)("scope" + c)
7777

7878

79+
@pytest.mark.parametrize("get_token_method", GET_TOKEN_METHODS)
80+
def test_subscription(get_token_method):
81+
"""The credential should accept a subscription ID"""
82+
83+
subscription = "foo subscription"
84+
credential = AzureCliCredential(subscription=subscription)
85+
assert credential.subscription == subscription
86+
87+
def fake_check_output(command_line, **_):
88+
assert f'--subscription "{subscription}"' in command_line[-1]
89+
return json.dumps(
90+
{
91+
"expiresOn": datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f"),
92+
"accessToken": "access-token",
93+
"subscription": subscription,
94+
"tenant": "tenant",
95+
"tokenType": "Bearer",
96+
}
97+
)
98+
99+
with mock.patch("shutil.which", return_value="az"):
100+
with mock.patch(CHECK_OUTPUT, fake_check_output):
101+
token = getattr(credential, get_token_method)("scope")
102+
assert token.token == "access-token"
103+
104+
105+
def test_invalid_subscriptons():
106+
"""Subscriptions with invalid characters should raise ValueErrors."""
107+
108+
for c in INVALID_SUBSCRIPTION_CHARACTERS:
109+
with pytest.raises(ValueError):
110+
AzureCliCredential(subscription="subscription" + c)
111+
112+
79113
@pytest.mark.parametrize("get_token_method", GET_TOKEN_METHODS)
80114
def test_get_token(get_token_method):
81115
"""The credential should parse the CLI's output to an AccessToken"""

sdk/identity/azure-identity/tests/test_cli_credential_async.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from azure.core.exceptions import ClientAuthenticationError
1818
import pytest
1919

20-
from helpers import INVALID_CHARACTERS, GET_TOKEN_METHODS
20+
from helpers import INVALID_CHARACTERS, INVALID_SUBSCRIPTION_CHARACTERS, GET_TOKEN_METHODS
2121
from helpers_async import get_completed_future
2222
from test_cli_credential import TEST_ERROR_OUTPUTS
2323

@@ -74,6 +74,41 @@ async def test_invalid_scopes(get_token_method):
7474
await getattr(AzureCliCredential(), get_token_method)("https://scope" + c)
7575

7676

77+
@pytest.mark.parametrize("get_token_method", GET_TOKEN_METHODS)
78+
async def test_subscription(get_token_method):
79+
"""The credential should accept a subscription ID"""
80+
81+
subscription = "foo subscription"
82+
credential = AzureCliCredential(subscription=subscription)
83+
assert credential.subscription == subscription
84+
85+
async def fake_exec(*args, **_):
86+
assert f'--subscription "{subscription}"' in args[-1]
87+
output = json.dumps(
88+
{
89+
"expiresOn": datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f"),
90+
"accessToken": "access-token",
91+
"subscription": subscription,
92+
"tenant": "tenant",
93+
"tokenType": "Bearer",
94+
}
95+
).encode()
96+
return mock.Mock(communicate=mock.Mock(return_value=get_completed_future((output, b""))), returncode=0)
97+
98+
with mock.patch("shutil.which", return_value="az"):
99+
with mock.patch(SUBPROCESS_EXEC, fake_exec):
100+
token = await getattr(credential, get_token_method)("scope")
101+
assert token.token == "access-token"
102+
103+
104+
def test_invalid_subscriptons():
105+
"""Subscriptions with invalid characters should raise ValueErrors."""
106+
107+
for c in INVALID_SUBSCRIPTION_CHARACTERS:
108+
with pytest.raises(ValueError):
109+
AzureCliCredential(subscription="subscription" + c)
110+
111+
77112
async def test_close():
78113
"""The credential must define close, although it's a no-op because the credential has no transport"""
79114

0 commit comments

Comments
 (0)