diff --git a/tools/paconn-cli/paconn/__init__.py b/tools/paconn-cli/paconn/__init__.py index d40051dda6..e535368a73 100644 --- a/tools/paconn-cli/paconn/__init__.py +++ b/tools/paconn-cli/paconn/__init__.py @@ -20,3 +20,6 @@ _UPDATE = 'update' _VALIDATE = 'validate' _CONVERT = 'convert' +_LIST = 'list' +_SHARE = 'share' +_DELETE = 'delete' diff --git a/tools/paconn-cli/paconn/apimanager/powerappsrp.py b/tools/paconn-cli/paconn/apimanager/powerappsrp.py index f7da39a218..864afde160 100644 --- a/tools/paconn-cli/paconn/apimanager/powerappsrp.py +++ b/tools/paconn-cli/paconn/apimanager/powerappsrp.py @@ -129,3 +129,48 @@ def generate_resource_storage(self, environment): payload=payload) return json.loads(response.text) + + def modify_permissions(self, environment, connector_id, permissions_data): + """ + Modifies permissions for a custom connector. + """ + api = urljoin('apis/', connector_id + '/modifyPermissions') + + endpoint = self.api_manager.construct_url( + path=api, + query=PowerAppsRP._get_filter_query(environment)) + + # Build the payload in the expected format + payload = { + "put": [ + { + "properties": permissions_data + } + ] + } + + response = self.api_manager.request( + verb='POST', + endpoint=endpoint, + payload=payload, + headers=self.rp_headers) + + return response.text + + def delete_connector(self, environment, connector_id): + """ + Deletes a custom connector. + """ + api = urljoin('apis/', connector_id) + + endpoint = self.api_manager.construct_url( + path=api, + query=PowerAppsRP._get_filter_query(environment)) + + response = self.api_manager.request( + verb='DELETE', + endpoint=endpoint, + headers=self.rp_headers) + + return response.text + diff --git a/tools/paconn-cli/paconn/authentication/auth.py b/tools/paconn-cli/paconn/authentication/auth.py index 7b4254fc16..eaf9e5c647 100644 --- a/tools/paconn-cli/paconn/authentication/auth.py +++ b/tools/paconn-cli/paconn/authentication/auth.py @@ -14,7 +14,7 @@ from paconn.authentication.tokenmanager import TokenManager -def get_authentication(settings, force_authenticate): +def get_authentication(settings, force_authenticate, client_secret=None): """ Logs the user in and saves the token in a file. """ @@ -31,7 +31,11 @@ def get_authentication(settings, force_authenticate): resource=settings.resource, authority_url=settings.authority_url) - credentials = profile.authenticate_device_code() + # Use service principal authentication if client_secret is provided + if client_secret: + credentials = profile.authenticate_service_principal(client_secret) + else: + credentials = profile.authenticate_device_code() tokenmanager.write(credentials) diff --git a/tools/paconn-cli/paconn/authentication/profile.py b/tools/paconn-cli/paconn/authentication/profile.py index 44f2faa2be..8540766d80 100644 --- a/tools/paconn-cli/paconn/authentication/profile.py +++ b/tools/paconn-cli/paconn/authentication/profile.py @@ -11,6 +11,8 @@ from urllib.parse import urljoin # AADTokenCredentials for multi-factor authentication from msrestazure.azure_active_directory import AADTokenCredentials +import requests +import json class Profile: @@ -53,3 +55,66 @@ def authenticate_device_code(self): client_id=self.client_id) return credentials.token + + def _get_service_principal_object_id(self, graph_token, client_id): + """ + Get the object ID of the service principal using Microsoft Graph API. + """ + try: + # Extract access token from the ADAL token response + access_token = graph_token.get('accessToken') or graph_token.get('access_token') + if not access_token: + return client_id + + # Microsoft Graph API endpoint to get service principal by appId + url = f"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{client_id}'" + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json' + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + + data = response.json() + if data.get('value') and len(data['value']) > 0: + return data['value'][0]['id'] # This is the object ID + else: + # Fallback to client_id if we can't find the service principal + return client_id + except Exception: + # Fallback to client_id if Graph API call fails + return client_id + + def authenticate_service_principal(self, client_secret): + """ + Authenticate using service principal credentials. + """ + context = self._get_authentication_context() + + # First, get a token for Microsoft Graph to look up the service principal object ID + graph_token = context.acquire_token_with_client_credentials( + resource="https://graph.microsoft.com/", + client_id=self.client_id, + client_secret=client_secret) + + # Get the service principal's actual object ID + sp_object_id = self._get_service_principal_object_id( + graph_token, + self.client_id) + + # Now get the token for the actual resource + mgmt_token = context.acquire_token_with_client_credentials( + resource=self.resource, + client_id=self.client_id, + client_secret=client_secret) + + credentials = AADTokenCredentials( + token=mgmt_token, + client_id=self.client_id) + + # Add the correct oid (service principal object ID) + token = credentials.token + token['oid'] = sp_object_id + + return token diff --git a/tools/paconn-cli/paconn/commands/commands.py b/tools/paconn-cli/paconn/commands/commands.py index 00d9c23066..b2487ad833 100644 --- a/tools/paconn-cli/paconn/commands/commands.py +++ b/tools/paconn-cli/paconn/commands/commands.py @@ -11,7 +11,7 @@ from knack.commands import CommandGroup from paconn import __CLI_NAME__ -from paconn import _COMMAND_GROUP, _LOGIN, _LOGOUT, _DOWNLOAD, _CREATE, _UPDATE, _VALIDATE, _CONVERT +from paconn import _COMMAND_GROUP, _LOGIN, _LOGOUT, _DOWNLOAD, _CREATE, _UPDATE, _VALIDATE, _CONVERT, _LIST, _SHARE, _DELETE # pylint: disable=unused-argument @@ -42,3 +42,12 @@ def operation_group(name): with CommandGroup(self, _COMMAND_GROUP, operation_group(_CONVERT)) as command_group: command_group.command(_CONVERT, _CONVERT) + + with CommandGroup(self, _COMMAND_GROUP, operation_group(_LIST)) as command_group: + command_group.command(_LIST, _LIST) + + with CommandGroup(self, _COMMAND_GROUP, operation_group(_SHARE)) as command_group: + command_group.command(_SHARE, _SHARE) + + with CommandGroup(self, _COMMAND_GROUP, operation_group(_DELETE)) as command_group: + command_group.command(_DELETE, _DELETE) diff --git a/tools/paconn-cli/paconn/commands/delete.py b/tools/paconn-cli/paconn/commands/delete.py new file mode 100644 index 0000000000..3aef64f381 --- /dev/null +++ b/tools/paconn-cli/paconn/commands/delete.py @@ -0,0 +1,76 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ----------------------------------------------------------------------------- +""" +Delete command. +""" + +from paconn import _DELETE +from paconn.common.util import display +from paconn.settings.util import load_powerapps_and_flow_rp +from paconn.settings.settingsbuilder import SettingsBuilder +from knack.prompting import prompt_y_n + + +def delete( + environment, + connector_id, + force, + powerapps_url, + powerapps_version, + settings_file): + """ + Delete command to remove a custom connector. + """ + # Get settings + settings = SettingsBuilder.get_settings( + environment=environment, + settings_file=settings_file, + connector_id=connector_id, + powerapps_url=powerapps_url, + powerapps_version=powerapps_version, + api_properties=None, + api_definition=None, + icon=None, + script=None) + + powerapps_rp, _ = load_powerapps_and_flow_rp( + settings=settings, + command_context=_DELETE) + + # Get connector info for display purposes + try: + connector_info = powerapps_rp.get_connector( + environment=settings.environment, + connector_id=settings.connector_id) + + connector_name = connector_info.get('properties', {}).get('displayName', settings.connector_id) + display(f'Target connector: {connector_name} ({settings.connector_id})') + except Exception: + # If we can't get connector info, just proceed with the ID + connector_name = settings.connector_id + display(f'Target connector: {settings.connector_id}') + + # Confirm deletion unless force is specified + if not force: + confirm = prompt_y_n(f'Are you sure you want to delete connector "{connector_name}"? ' + 'This action cannot be undone.') + if not confirm: + display('Delete operation cancelled.') + return + + # Perform the deletion + try: + response_text = powerapps_rp.delete_connector( + environment=settings.environment, + connector_id=settings.connector_id) + + display(f'Connector "{connector_name}" deleted successfully.') + if response_text and response_text.strip(): + display(f'Response: {response_text}') + + except Exception as e: + display(f'Error deleting connector: {str(e)}') + raise diff --git a/tools/paconn-cli/paconn/commands/list.py b/tools/paconn-cli/paconn/commands/list.py new file mode 100644 index 0000000000..8b49c19c67 --- /dev/null +++ b/tools/paconn-cli/paconn/commands/list.py @@ -0,0 +1,81 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ----------------------------------------------------------------------------- +""" +List command. +""" + +from paconn import _LIST + +from paconn.common.util import display +from paconn.settings.util import load_powerapps_and_flow_rp +from paconn.settings.settingsbuilder import SettingsBuilder + +# Constants for response parsing +_PROPERTIES = 'properties' +_IS_CUSTOM_API = 'isCustomApi' + + +def list( + environment, + powerapps_url, + powerapps_version, + settings_file): + """ + List command. + """ + # Get settings + settings = SettingsBuilder.get_settings( + environment=environment, + settings_file=settings_file, + api_properties=None, + api_definition=None, + icon=None, + script=None, + connector_id=None, + powerapps_url=powerapps_url, + powerapps_version=powerapps_version) + + powerapps_rp, _ = load_powerapps_and_flow_rp( + settings=settings, + command_context=_LIST) + + connectors = powerapps_rp.get_all_connectors( + environment=settings.environment) + + if 'value' in connectors and connectors['value']: + # Filter to only custom connectors + all_connectors = connectors['value'] + custom_connectors = [ + conn for conn in all_connectors + if conn.get(_PROPERTIES, {}).get(_IS_CUSTOM_API, False) + ] + + if custom_connectors: + # Return structured data with PascalCase keys for knack to format + result_data = [] + for connector in custom_connectors: + properties = connector.get('properties', {}) + created_by = properties.get('createdBy', {}) + + connector_data = { + 'Name': connector.get('name', ''), + 'Id': connector.get('id', ''), + 'Type': connector.get('type', ''), + 'DisplayName': properties.get('displayName', ''), + 'IconUri': properties.get('iconUri', ''), + 'IconBrandColor': properties.get('iconBrandColor', ''), + 'Description': properties.get('description', ''), + 'CreatedBy': created_by.get('displayName', '') if created_by else '' + } + result_data.append(connector_data) + + return result_data + else: + display('No custom connectors found in environment {}.'.format(settings.environment)) + return [] + else: + display('No custom connectors found in environment {}.'.format(settings.environment)) + return [] diff --git a/tools/paconn-cli/paconn/commands/login.py b/tools/paconn-cli/paconn/commands/login.py index bfc3940b68..15197e2ead 100644 --- a/tools/paconn-cli/paconn/commands/login.py +++ b/tools/paconn-cli/paconn/commands/login.py @@ -12,7 +12,7 @@ from paconn.settings.settingsbuilder import SettingsBuilder -def login(client_id, tenant, authority_url, resource, settings_file, force): +def login(client_id, tenant, authority_url, resource, settings_file, force, client_secret=None): """ Login command. """ @@ -26,5 +26,6 @@ def login(client_id, tenant, authority_url, resource, settings_file, force): get_authentication( settings=settings, - force_authenticate=force) + force_authenticate=force, + client_secret=client_secret) display('Login successful.') diff --git a/tools/paconn-cli/paconn/commands/params.py b/tools/paconn-cli/paconn/commands/params.py index e93df34d46..f96457e952 100644 --- a/tools/paconn-cli/paconn/commands/params.py +++ b/tools/paconn-cli/paconn/commands/params.py @@ -9,7 +9,7 @@ """ from knack.arguments import ArgumentsContext -from paconn import _LOGIN, _DOWNLOAD, _CREATE, _UPDATE, _VALIDATE, _CONVERT +from paconn import _LOGIN, _DOWNLOAD, _CREATE, _UPDATE, _VALIDATE, _CONVERT, _LIST, _SHARE, _DELETE CLIENT_SECRET = 'client_secret' CLIENT_SECRET_OPTIONS = ['--secret', '-r'] @@ -59,6 +59,10 @@ DESTINATION_OPTIONS = ['--dest', '-dst'] DESTINATION_HELP = 'Destination directory for the converted connector files.' +PERMISSIONS_FILE = 'permissions_file' +PERMISSIONS_FILE_OPTIONS = ['--permissions', '-perm'] +PERMISSIONS_FILE_HELP = 'Location of the permissions JSON file containing roleName and principal information.' + # pylint: disable=unused-argument def load_arguments(self, command): @@ -90,6 +94,12 @@ def load_arguments(self, command): type=str, required=False, help='Resource URL for login.') + arg_context.argument( + 'client_secret', + options_list=['--secret', '-k'], + type=str, + required=False, + help='Client secret for service principal authentication.') arg_context.argument( SETTINGS, options_list=SETTINGS_OPTIONS, @@ -325,3 +335,110 @@ def load_arguments(self, command): type=str, required=False, help=SETTINGS_HELP) + + with ArgumentsContext(self, _LIST) as arg_context: + arg_context.argument( + ENVIRONMENT, + options_list=ENVIRONMENT_OPTIONS, + type=str, + required=False, + help=ENVIRONMENT_HELP) + arg_context.argument( + POWERAPPS_URL, + options_list=POWERAPPS_URL_OPTIONS, + type=str, + required=False, + help=POWERAPPS_URL_HELP) + arg_context.argument( + POWERAPPS_VERSION, + options_list=POWERAPPS_VERSION_OPTIONS, + type=str, + required=False, + help=POWERAPPS_VERSION_HELP) + arg_context.argument( + SETTINGS, + options_list=SETTINGS_OPTIONS, + type=str, + required=False, + help=SETTINGS_HELP) + + with ArgumentsContext(self, _SHARE) as arg_context: + arg_context.argument( + ENVIRONMENT, + options_list=ENVIRONMENT_OPTIONS, + type=str, + required=False, + help=ENVIRONMENT_HELP) + arg_context.argument( + CONNECTOR_ID, + options_list=CONNECTOR_ID_OPTIONS, + type=str, + required=False, + help=CONNECTOR_ID_HELP) + arg_context.argument( + PERMISSIONS_FILE, + options_list=PERMISSIONS_FILE_OPTIONS, + type=str, + required=True, + help=PERMISSIONS_FILE_HELP) + arg_context.argument( + POWERAPPS_URL, + options_list=POWERAPPS_URL_OPTIONS, + type=str, + required=False, + help=POWERAPPS_URL_HELP) + arg_context.argument( + POWERAPPS_VERSION, + options_list=POWERAPPS_VERSION_OPTIONS, + type=str, + required=False, + help=POWERAPPS_VERSION_HELP) + arg_context.argument( + SETTINGS, + options_list=SETTINGS_OPTIONS, + type=str, + required=False, + help=SETTINGS_HELP) + + + with ArgumentsContext(self, _DELETE) as arg_context: + arg_context.argument( + ENVIRONMENT, + options_list=ENVIRONMENT_OPTIONS, + type=str, + required=False, + help=ENVIRONMENT_HELP) + arg_context.argument( + CONNECTOR_ID, + options_list=CONNECTOR_ID_OPTIONS, + type=str, + required=False, + help=CONNECTOR_ID_HELP) + arg_context.argument( + 'force', + options_list=['--force', '-f'], + type=bool, + required=False, + nargs='?', + default=False, + const=True, + help='Force deletion without confirmation prompt.') + arg_context.argument( + POWERAPPS_URL, + options_list=POWERAPPS_URL_OPTIONS, + type=str, + required=False, + help=POWERAPPS_URL_HELP) + arg_context.argument( + POWERAPPS_VERSION, + options_list=POWERAPPS_VERSION_OPTIONS, + type=str, + required=False, + help=POWERAPPS_VERSION_HELP) + arg_context.argument( + SETTINGS, + options_list=SETTINGS_OPTIONS, + type=str, + required=False, + help=SETTINGS_HELP) + diff --git a/tools/paconn-cli/paconn/commands/share.py b/tools/paconn-cli/paconn/commands/share.py new file mode 100644 index 0000000000..882ea2897e --- /dev/null +++ b/tools/paconn-cli/paconn/commands/share.py @@ -0,0 +1,68 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ----------------------------------------------------------------------------- +""" +Share command. +""" + +import json +import os + +from paconn import _SHARE +from paconn.common.util import display +from paconn.settings.util import load_powerapps_and_flow_rp +from paconn.settings.settingsbuilder import SettingsBuilder + + +def share( + environment, + connector_id, + permissions_file, + powerapps_url, + powerapps_version, + settings_file): + """ + Share command to modify connector permissions. + """ + # Get settings + settings = SettingsBuilder.get_settings( + environment=environment, + settings_file=settings_file, + connector_id=connector_id, + powerapps_url=powerapps_url, + powerapps_version=powerapps_version, + api_properties=None, + api_definition=None, + icon=None, + script=None) + + powerapps_rp, _ = load_powerapps_and_flow_rp( + settings=settings, + command_context=_SHARE) + + # Load and validate the permissions JSON file + if not os.path.exists(permissions_file): + raise ValueError(f"Permissions file not found: {permissions_file}") + + try: + with open(permissions_file, 'r', encoding='utf-8') as f: + permissions_data = json.load(f) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in permissions file: {e}") + + # Validate required fields in the permissions data + required_fields = ['roleName', 'principal'] + for field in required_fields: + if field not in permissions_data: + raise ValueError(f"Missing required field in permissions file: {field}") + + # Call the API to modify permissions + response_text = powerapps_rp.modify_permissions( + environment=settings.environment, + connector_id=settings.connector_id, + permissions_data=permissions_data) + + display(f'Permissions modified successfully for connector {settings.connector_id}.') + display(f'Response: {response_text}')