diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index 386ba088608..280e5da1d78 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -117,6 +117,17 @@ # Please maintain compatibility in both interfaces and functionalities" +def _is_service_principal_auth(cli_ctx): + """Check if current authentication is via Service Principal.""" + from azure.cli.core._profile import Profile + from azure.cli.core.commands.client_factory import get_subscription_id + profile = Profile(cli_ctx=cli_ctx) + subscription_id = get_subscription_id(cli_ctx) + account = profile.get_subscription(subscription=subscription_id) + # Service principals have user.type == 'servicePrincipal' + return account.get('user', {}).get('type') == 'servicePrincipal' + + def create_webapp(cmd, resource_group_name, name, plan, runtime=None, startup_file=None, # pylint: disable=too-many-statements,too-many-branches deployment_container_image_name=None, deployment_source_url=None, deployment_source_branch='master', deployment_local_git=None, sitecontainers_app=None, @@ -3997,6 +4008,13 @@ def config_source_control(cmd, resource_group_name, name, repo_url, repository_t client = web_client_factory(cmd.cli_ctx) location = _get_location_from_webapp(client, resource_group_name, name) + # Check for Service Principal + GitHub Actions incompatibility + if github_action and _is_service_principal_auth(cmd.cli_ctx): + raise ValidationError( + "GitHub Actions deployment cannot be configured with Service Principal authentication. " + "Use 'az webapp deployment github-actions add' instead, which supports Service Principal workflows." + ) + from azure.mgmt.web.models import SiteSourceControl, SourceControl if git_token: sc = SourceControl(location=location, source_control_name='GitHub', token=git_token) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index 853eadc1edd..586848f42af 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -33,7 +33,8 @@ add_github_actions, update_app_settings, update_application_settings_polling, - update_webapp) + update_webapp, + config_source_control) # pylint: disable=line-too-long from azure.cli.core.profiles import ResourceType @@ -639,6 +640,114 @@ def test_update_webapp_platform_release_channel_latest(self): self.assertEqual(result.additional_properties["properties"]["platformReleaseChannel"], "Latest") +class TestServicePrincipalDeploymentSource(unittest.TestCase): + """Tests for Service Principal authentication detection in deployment source config""" + + @mock.patch('azure.cli.command_modules.appservice.custom._is_service_principal_auth') + @mock.patch('azure.cli.command_modules.appservice.custom._get_location_from_webapp') + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + def test_config_source_control_with_sp_and_github_action_raises_error( + self, web_client_factory_mock, get_location_mock, is_sp_auth_mock + ): + """Test that SP auth + --github-action raises ValidationError""" + from azure.cli.core.azclierror import ValidationError + + # Setup mocks + is_sp_auth_mock.return_value = True + get_location_mock.return_value = 'eastus' + + cmd = _get_test_cmd() + + # Execute and assert + with self.assertRaises(ValidationError) as context: + config_source_control( + cmd, + resource_group_name='test-rg', + name='test-app', + repo_url='https://github.com/test/repo', + github_action=True + ) + + self.assertIn('Service Principal authentication', str(context.exception)) + self.assertIn('az webapp deployment github-actions add', str(context.exception)) + + @mock.patch('azure.cli.command_modules.appservice.custom._is_service_principal_auth') + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + @mock.patch('azure.cli.command_modules.appservice.custom._get_location_from_webapp') + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + def test_config_source_control_with_user_and_github_action_succeeds( + self, web_client_factory_mock, get_location_mock, generic_site_op_mock, is_sp_auth_mock + ): + """Test that user auth + --github-action passes through (no error raised)""" + + # Setup mocks + is_sp_auth_mock.return_value = False # User authentication + get_location_mock.return_value = 'eastus' + + # Mock the site operation to return a mock response + mock_poller = mock.Mock() + mock_poller.done.return_value = True + mock_response = mock.Mock() + mock_response.git_hub_action_configuration = None + mock_poller.result.return_value = mock_response + generic_site_op_mock.return_value = mock_poller + + cmd = _get_test_cmd() + + # Execute - should not raise an error + config_source_control( + cmd, + resource_group_name='test-rg', + name='test-app', + repo_url='https://github.com/test/repo', + github_action=True + ) + + # Assert _generic_site_operation was called with correct SiteSourceControl + generic_site_op_mock.assert_called_once() + call_args = generic_site_op_mock.call_args + source_control_arg = call_args[0][5] + self.assertTrue(source_control_arg.is_git_hub_action) + + @mock.patch('azure.cli.command_modules.appservice.custom._is_service_principal_auth') + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + @mock.patch('azure.cli.command_modules.appservice.custom._get_location_from_webapp') + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + def test_config_source_control_with_sp_without_github_action_succeeds( + self, web_client_factory_mock, get_location_mock, generic_site_op_mock, is_sp_auth_mock + ): + """Test that SP auth without --github-action passes through (no error raised)""" + + # Setup mocks + is_sp_auth_mock.return_value = True # Service Principal authentication + get_location_mock.return_value = 'eastus' + + # Mock the site operation to return a mock response + mock_poller = mock.Mock() + mock_poller.done.return_value = True + mock_response = mock.Mock() + mock_response.git_hub_action_configuration = None + mock_poller.result.return_value = mock_response + generic_site_op_mock.return_value = mock_poller + + cmd = _get_test_cmd() + + # Execute - should not raise an error when github_action is None/False + config_source_control( + cmd, + resource_group_name='test-rg', + name='test-app', + repo_url='https://github.com/test/repo', + github_action=None # Not using GitHub Actions + ) + + # Assert _generic_site_operation was called with correct SiteSourceControl + generic_site_op_mock.assert_called_once() + call_args = generic_site_op_mock.call_args + source_control_arg = call_args[0][5] + self.assertFalse(source_control_arg.is_git_hub_action) + + class FakedResponse: # pylint: disable=too-few-public-methods def __init__(self, status_code): self.status_code = status_code