From 1ce40e9b8c2defae46e423d48e796c2ec3122aef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 06:03:46 +0000 Subject: [PATCH 1/7] Initial plan From 528dca27440c668b1f1fc6dec2beb6f761ced282 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 06:08:02 +0000 Subject: [PATCH 2/7] Improve Graph accessDenied guidance for az rest HTTP errors Agent-Logs-Url: https://github.com/Azure/azure-cli/sessions/434b4544-999c-4413-9df1-8ef7b472e216 Co-authored-by: a0x1ab <59631311+a0x1ab@users.noreply.github.com> --- .../azure/cli/core/tests/test_util.py | 23 +++++++++++- src/azure-cli-core/azure/cli/core/util.py | 37 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_util.py b/src/azure-cli-core/azure/cli/core/tests/test_util.py index ae329ed65b7..2588fed5d1d 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_util.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_util.py @@ -5,6 +5,7 @@ # pylint: disable=line-too-long from collections import namedtuple +import io import os import sys import unittest @@ -17,7 +18,9 @@ (get_file_json, truncate_text, shell_safe_json_parse, b64_to_hex, hash_string, random_string, open_page_in_browser, can_launch_browser, handle_exception, ConfiguredDefaultSetter, send_raw_request, should_disable_connection_verify, parse_proxy_resource_id, get_az_user_agent, get_az_rest_user_agent, - _get_parent_proc_name, is_wsl, run_cmd, run_az_cmd, roughly_parse_command) + _get_parent_proc_name, is_wsl, run_cmd, run_az_cmd, roughly_parse_command, + GRAPH_ACCESS_DENIED_RECOMMENDATION) +from azure.cli.core import azclierror from azure.cli.core.mock import DummyCli @@ -613,6 +616,24 @@ def test_handle_exception_httpoperationerror_no_response_text(self, mock_logger_ self.assertIn(str(mock_http_error), mock_logger_error.call_args[0][0]) self.assertEqual(ex_result, 1) + @mock.patch('azure.cli.core.azclierror.logger.error', autospec=True) + def test_handle_exception_http_error_graph_access_denied_adds_recommendation(self, mock_logger_error): + mock_response = mock.MagicMock() + mock_response.status_code = 403 + mock_response.headers = {} + mock_response.url = 'https://graph.microsoft.com/v1.0/policies/authenticationStrengthPolicies/' + mock_response.text = json.dumps({"error": {"code": "accessDenied", "message": "Request Authorization failed"}}) + mock_response.json.return_value = json.loads(mock_response.text) + http_error = azclierror.HTTPError('Forbidden({"error":{"code":"accessDenied"}})', mock_response) + + with mock.patch('sys.stderr', new=io.StringIO()) as stderr: + ex_result = handle_exception(http_error) + + self.assertTrue(mock_logger_error.called) + self.assertIn('Forbidden', str(mock_logger_error.call_args[0][0])) + self.assertEqual(ex_result, 1) + self.assertIn(GRAPH_ACCESS_DENIED_RECOMMENDATION, stderr.getvalue()) + @staticmethod def _get_mock_HttpOperationError(response_text): from msrest.exceptions import HttpOperationError diff --git a/src/azure-cli-core/azure/cli/core/util.py b/src/azure-cli-core/azure/cli/core/util.py index b693ee00518..965ae620cd3 100644 --- a/src/azure-cli-core/azure/cli/core/util.py +++ b/src/azure-cli-core/azure/cli/core/util.py @@ -27,6 +27,13 @@ QUERY_REFERENCE = ("To learn more about --query, please visit: " "'https://learn.microsoft.com/cli/azure/query-azure-cli'") +GRAPH_ACCESS_DENIED_RECOMMENDATION = ( + "If this Microsoft Graph API call requires privileged permissions (for example " + "Policy.ReadWrite.ConditionalAccess), Azure CLI's first-party app may not be pre-authorized " + "for that scope (AADSTS65002). Use 'az login --service-principal' with an app registration " + "that has the required Microsoft Graph permissions granted." +) + _PROXYID_RE = re.compile( '(?i)/subscriptions/(?P[^/]*)(/resourceGroups/(?P[^/]*))?' @@ -99,6 +106,9 @@ def handle_exception(ex): # pylint: disable=too-many-locals, too-many-statement az_error.set_recommendation("Interactive authentication is needed. Please run:\naz logout\naz login") else: az_error = azclierror.UnclassifiedUserFault(ex) + recommendation = _get_graph_access_denied_recommendation(ex.response) + if recommendation: + az_error.set_recommendation(recommendation) elif isinstance(ex, CLIError): # TODO: Fine-grained analysis here @@ -184,6 +194,33 @@ def extract_common_error_message(ex): return error_msg +def _get_graph_access_denied_recommendation(response): + if getattr(response, 'status_code', None) != 403: + return None + + response_url = getattr(response, 'url', '') + if not isinstance(response_url, str) or not response_url.lower().startswith('https://graph.microsoft.com/'): + return None + + try: + response_json = response.json() + except Exception: # pylint: disable=broad-except + try: + response_json = json.loads(getattr(response, 'text', '')) + except Exception: # pylint: disable=broad-except + return None + + error = response_json.get('error') if isinstance(response_json, dict) else None + if not isinstance(error, dict): + return None + + error_code = error.get('code') + if isinstance(error_code, str) and error_code.lower() == 'accessdenied': + return GRAPH_ACCESS_DENIED_RECOMMENDATION + + return None + + def extract_http_operation_error(ex): error_msg = None status_code = 'Unknown Code' From 1094a7e7f96688374c8cf0c21140288eff1c4c7f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 06:08:39 +0000 Subject: [PATCH 3/7] Add negative tests for Graph accessDenied recommendation guardrails Agent-Logs-Url: https://github.com/Azure/azure-cli/sessions/434b4544-999c-4413-9df1-8ef7b472e216 Co-authored-by: a0x1ab <59631311+a0x1ab@users.noreply.github.com> --- .../azure/cli/core/tests/test_util.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_util.py b/src/azure-cli-core/azure/cli/core/tests/test_util.py index 2588fed5d1d..482d4cd6729 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_util.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_util.py @@ -634,6 +634,31 @@ def test_handle_exception_http_error_graph_access_denied_adds_recommendation(sel self.assertEqual(ex_result, 1) self.assertIn(GRAPH_ACCESS_DENIED_RECOMMENDATION, stderr.getvalue()) + @mock.patch('azure.cli.core.azclierror.logger.error', autospec=True) + def test_handle_exception_http_error_without_graph_access_denied_no_recommendation(self, _): + test_cases = [ + (403, 'https://management.azure.com/subscriptions?api-version=2020-01-01', + {"error": {"code": "accessDenied", "message": "Request Authorization failed"}}), + (404, 'https://graph.microsoft.com/v1.0/policies/authenticationStrengthPolicies/', + {"error": {"code": "accessDenied", "message": "Request Authorization failed"}}) + ] + + for status_code, url, payload in test_cases: + with self.subTest(status_code=status_code, url=url): + mock_response = mock.MagicMock() + mock_response.status_code = status_code + mock_response.headers = {} + mock_response.url = url + mock_response.text = json.dumps(payload) + mock_response.json.return_value = payload + http_error = azclierror.HTTPError('Request failed', mock_response) + + with mock.patch('sys.stderr', new=io.StringIO()) as stderr: + ex_result = handle_exception(http_error) + + self.assertEqual(ex_result, 1) + self.assertNotIn(GRAPH_ACCESS_DENIED_RECOMMENDATION, stderr.getvalue()) + @staticmethod def _get_mock_HttpOperationError(response_text): from msrest.exceptions import HttpOperationError From c72868bfc3a04cafc24eef1f203eb545ed1cf33d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 06:09:11 +0000 Subject: [PATCH 4/7] Expand Graph accessDenied test coverage for case-insensitive codes Agent-Logs-Url: https://github.com/Azure/azure-cli/sessions/434b4544-999c-4413-9df1-8ef7b472e216 Co-authored-by: a0x1ab <59631311+a0x1ab@users.noreply.github.com> --- .../azure/cli/core/tests/test_util.py | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_util.py b/src/azure-cli-core/azure/cli/core/tests/test_util.py index 482d4cd6729..8e434a8a5a5 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_util.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_util.py @@ -618,21 +618,23 @@ def test_handle_exception_httpoperationerror_no_response_text(self, mock_logger_ @mock.patch('azure.cli.core.azclierror.logger.error', autospec=True) def test_handle_exception_http_error_graph_access_denied_adds_recommendation(self, mock_logger_error): - mock_response = mock.MagicMock() - mock_response.status_code = 403 - mock_response.headers = {} - mock_response.url = 'https://graph.microsoft.com/v1.0/policies/authenticationStrengthPolicies/' - mock_response.text = json.dumps({"error": {"code": "accessDenied", "message": "Request Authorization failed"}}) - mock_response.json.return_value = json.loads(mock_response.text) - http_error = azclierror.HTTPError('Forbidden({"error":{"code":"accessDenied"}})', mock_response) + for error_code in ['accessDenied', 'ACCESSDENIED']: + with self.subTest(error_code=error_code): + mock_response = mock.MagicMock() + mock_response.status_code = 403 + mock_response.headers = {} + mock_response.url = 'https://graph.microsoft.com/v1.0/policies/authenticationStrengthPolicies/' + mock_response.text = json.dumps({"error": {"code": error_code, "message": "Request Authorization failed"}}) + mock_response.json.return_value = json.loads(mock_response.text) + http_error = azclierror.HTTPError('Forbidden({"error":{"code":"accessDenied"}})', mock_response) - with mock.patch('sys.stderr', new=io.StringIO()) as stderr: - ex_result = handle_exception(http_error) + with mock.patch('sys.stderr', new=io.StringIO()) as stderr: + ex_result = handle_exception(http_error) - self.assertTrue(mock_logger_error.called) - self.assertIn('Forbidden', str(mock_logger_error.call_args[0][0])) - self.assertEqual(ex_result, 1) - self.assertIn(GRAPH_ACCESS_DENIED_RECOMMENDATION, stderr.getvalue()) + self.assertTrue(mock_logger_error.called) + self.assertIn('Forbidden', str(mock_logger_error.call_args[0][0])) + self.assertEqual(ex_result, 1) + self.assertIn(GRAPH_ACCESS_DENIED_RECOMMENDATION, stderr.getvalue()) @mock.patch('azure.cli.core.azclierror.logger.error', autospec=True) def test_handle_exception_http_error_without_graph_access_denied_no_recommendation(self, _): From 9e216c6e91f62482fb232c19d36f550e877535e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 06:09:39 +0000 Subject: [PATCH 5/7] Refactor Graph accessDenied code check to named constant Agent-Logs-Url: https://github.com/Azure/azure-cli/sessions/434b4544-999c-4413-9df1-8ef7b472e216 Co-authored-by: a0x1ab <59631311+a0x1ab@users.noreply.github.com> --- src/azure-cli-core/azure/cli/core/util.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/util.py b/src/azure-cli-core/azure/cli/core/util.py index 965ae620cd3..29124a0a022 100644 --- a/src/azure-cli-core/azure/cli/core/util.py +++ b/src/azure-cli-core/azure/cli/core/util.py @@ -33,6 +33,7 @@ "for that scope (AADSTS65002). Use 'az login --service-principal' with an app registration " "that has the required Microsoft Graph permissions granted." ) +GRAPH_ACCESS_DENIED_ERROR_CODE = 'accessdenied' _PROXYID_RE = re.compile( @@ -215,7 +216,7 @@ def _get_graph_access_denied_recommendation(response): return None error_code = error.get('code') - if isinstance(error_code, str) and error_code.lower() == 'accessdenied': + if isinstance(error_code, str) and error_code.lower() == GRAPH_ACCESS_DENIED_ERROR_CODE: return GRAPH_ACCESS_DENIED_RECOMMENDATION return None From 34f8b5bf630d980882df9f5bc37ab36a2038ca79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 06:10:13 +0000 Subject: [PATCH 6/7] Polish Graph accessDenied constants and test message consistency Agent-Logs-Url: https://github.com/Azure/azure-cli/sessions/434b4544-999c-4413-9df1-8ef7b472e216 Co-authored-by: a0x1ab <59631311+a0x1ab@users.noreply.github.com> --- src/azure-cli-core/azure/cli/core/tests/test_util.py | 2 +- src/azure-cli-core/azure/cli/core/util.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_util.py b/src/azure-cli-core/azure/cli/core/tests/test_util.py index 8e434a8a5a5..a1374d27f73 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_util.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_util.py @@ -626,7 +626,7 @@ def test_handle_exception_http_error_graph_access_denied_adds_recommendation(sel mock_response.url = 'https://graph.microsoft.com/v1.0/policies/authenticationStrengthPolicies/' mock_response.text = json.dumps({"error": {"code": error_code, "message": "Request Authorization failed"}}) mock_response.json.return_value = json.loads(mock_response.text) - http_error = azclierror.HTTPError('Forbidden({"error":{"code":"accessDenied"}})', mock_response) + http_error = azclierror.HTTPError(f'Forbidden({{"error":{{"code":"{error_code}"}}}})', mock_response) with mock.patch('sys.stderr', new=io.StringIO()) as stderr: ex_result = handle_exception(http_error) diff --git a/src/azure-cli-core/azure/cli/core/util.py b/src/azure-cli-core/azure/cli/core/util.py index 29124a0a022..1c1f0805548 100644 --- a/src/azure-cli-core/azure/cli/core/util.py +++ b/src/azure-cli-core/azure/cli/core/util.py @@ -33,7 +33,8 @@ "for that scope (AADSTS65002). Use 'az login --service-principal' with an app registration " "that has the required Microsoft Graph permissions granted." ) -GRAPH_ACCESS_DENIED_ERROR_CODE = 'accessdenied' +GRAPH_ENDPOINT_PREFIX = 'https://graph.microsoft.com/' +GRAPH_ACCESS_DENIED_ERROR_CODE_LOWER = 'accessdenied' _PROXYID_RE = re.compile( @@ -200,7 +201,7 @@ def _get_graph_access_denied_recommendation(response): return None response_url = getattr(response, 'url', '') - if not isinstance(response_url, str) or not response_url.lower().startswith('https://graph.microsoft.com/'): + if not isinstance(response_url, str) or not response_url.lower().startswith(GRAPH_ENDPOINT_PREFIX): return None try: @@ -216,7 +217,7 @@ def _get_graph_access_denied_recommendation(response): return None error_code = error.get('code') - if isinstance(error_code, str) and error_code.lower() == GRAPH_ACCESS_DENIED_ERROR_CODE: + if isinstance(error_code, str) and error_code.lower() == GRAPH_ACCESS_DENIED_ERROR_CODE_LOWER: return GRAPH_ACCESS_DENIED_RECOMMENDATION return None From 37f9b3b65e378f73f0c8ab48c4fd861cd6c96a8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 06:10:49 +0000 Subject: [PATCH 7/7] Rename Graph endpoint constant to explicit lowercase form Agent-Logs-Url: https://github.com/Azure/azure-cli/sessions/434b4544-999c-4413-9df1-8ef7b472e216 Co-authored-by: a0x1ab <59631311+a0x1ab@users.noreply.github.com> --- src/azure-cli-core/azure/cli/core/util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/util.py b/src/azure-cli-core/azure/cli/core/util.py index 1c1f0805548..3f9112041b8 100644 --- a/src/azure-cli-core/azure/cli/core/util.py +++ b/src/azure-cli-core/azure/cli/core/util.py @@ -33,7 +33,7 @@ "for that scope (AADSTS65002). Use 'az login --service-principal' with an app registration " "that has the required Microsoft Graph permissions granted." ) -GRAPH_ENDPOINT_PREFIX = 'https://graph.microsoft.com/' +GRAPH_ENDPOINT_PREFIX_LOWER = 'https://graph.microsoft.com/' GRAPH_ACCESS_DENIED_ERROR_CODE_LOWER = 'accessdenied' @@ -201,7 +201,7 @@ def _get_graph_access_denied_recommendation(response): return None response_url = getattr(response, 'url', '') - if not isinstance(response_url, str) or not response_url.lower().startswith(GRAPH_ENDPOINT_PREFIX): + if not isinstance(response_url, str) or not response_url.lower().startswith(GRAPH_ENDPOINT_PREFIX_LOWER): return None try: