diff --git a/databricks_cli/cli.py b/databricks_cli/cli.py index 0b0a8f1b..2b7ec7c0 100644 --- a/databricks_cli/cli.py +++ b/databricks_cli/cli.py @@ -25,6 +25,7 @@ from databricks_cli.configure.config import profile_option, debug_option from databricks_cli.libraries.cli import libraries_group +from databricks_cli.scim.cli import scim_group from databricks_cli.version import print_version_callback, version from databricks_cli.utils import CONTEXT_SETTINGS from databricks_cli.configure.cli import configure_cli @@ -62,6 +63,7 @@ def cli(): cli.add_command(secrets_group, name='secrets') cli.add_command(stack_group, name='stack') cli.add_command(groups_group, name='groups') +cli.add_command(scim_group, name='scim') cli.add_command(tokens_group, name='tokens') cli.add_command(instance_pools_group, name="instance-pools") cli.add_command(pipelines_group, name='pipelines') diff --git a/databricks_cli/scim/__init__.py b/databricks_cli/scim/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/databricks_cli/scim/api.py b/databricks_cli/scim/api.py new file mode 100644 index 00000000..ea7cf2d6 --- /dev/null +++ b/databricks_cli/scim/api.py @@ -0,0 +1,253 @@ +# Databricks CLI +# Copyright 2017 Databricks, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"), except +# that the use of services to which certain application programming +# interfaces (each, an "API") connect requires that the user first obtain +# a license for the use of the APIs from Databricks, Inc. ("Databricks"), +# by creating an account at www.databricks.com and agreeing to either (a) +# the Community Edition Terms of Service, (b) the Databricks Terms of +# Service, or (c) another written agreement between Licensee and Databricks +# for the use of the APIs. +# +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from databricks_cli.sdk import ScimService +from databricks_cli.utils import is_int + + +class ScimError(Exception): + pass + + +class ScimApi(object): + GROUP_NAME_FILTER = 'displayName eq {}' + USER_NAME_FILTER = 'userName eq {}' + + def __init__(self, api_client): + self.client = ScimService(api_client) + + def get_group(self, group_id=None, group_name=None): + if group_id is not None: + content = self.get_group_by_id(group_id) + else: + filters = self.GROUP_NAME_FILTER.format(group_name) + content = self.list_groups(filters=filters) + return content + + def get_user(self, user_id=None, user_name=None): + if user_id is not None: + content = self.get_user_by_id(user_id) + else: + filters = self.USER_NAME_FILTER.format(user_name) + content = self.list_users(filters=filters) + return content + + def get_user_id_for_user(self, user_name): + content = self.get_user(user_id=None, user_name=user_name) + return self._parse_id_from_json(name='user', value=user_name, + filters=self.USER_NAME_FILTER.format(user_name), + data=content) + + def get_group_id_for_group(self, group_name): + content = self.get_group(group_name=group_name) + return self._parse_id_from_json(name='group', value=group_name, + filters=self.GROUP_NAME_FILTER.format(group_name), + data=content) + + def get_group_name_for_group(self, group_id): + content = self.get_group(group_id=group_id) + return self._parse_name_from_json(id_value=group_id, value=group_id, data=content) + + def delete_user(self, user_id=None, user_name=None): + if user_id is None: + user_id = self.get_user_id_for_user(user_name) + content = self.delete_user_by_id(user_id) + + return content + + def list_users(self, filters=None, active=None): + # filtering on active doesn't work: + # https://help.databricks.com/hc/en-us/requests/24688 + content = self.client.users(filters) + + return self.filter_active_only(content, active) + + def get_user_by_id(self, user_id): + return self.client.user_by_id(user_id) + + def create_user(self, user_name=None, groups=None, entitlements=None, roles=None): + return self.client.create_user(user_name=user_name, groups=groups, + entitlements=entitlements, roles=roles) + + def create_user_json(self, json): + return self.client.create_user_json(data=json) + + def update_user_by_id(self, user_id, operation, path, values): + return self.client.update_user_by_id(user_id, operation, path, values) + + def overwrite_user_by_id(self, user_id, user_name, groups, entitlements, roles): + return self.client.overwrite_user_by_id(user_id, user_name, groups, entitlements, roles) + + def delete_user_by_id(self, user_id): + return self.client.delete_user_by_id(user_id) + + def list_groups(self, filters=None, group_id=None, group_name=None): + extra_filter = None + if group_id is not None: + # id is not supported. + # extra_filter = 'id eq {}'.format(group_id) + group = self.get_group(group_id=group_id) + group_name = group.get('displayName') + + if group_name is not None: + extra_filter = 'displayName eq {}'.format(group_name) + + if extra_filter is not None: + if filters is not None: + filters = filters + ' and {}'.format(extra_filter) + else: + filters = extra_filter + + return self.client.groups(filters) + + def get_group_by_id(self, group_id): + return self.client.group_by_id(group_id) + + def create_group(self, group_name, users): + user_ids = self.users_to_user_ids(users) + return self.client.create_group_internal(group_name=group_name, members=user_ids) + + def update_group_by_id(self, group_id, operation, values): + return self.client.update_group_by_id(group_id=group_id, operation=operation, values=values) + + def delete_group(self, group_id=None, group_name=None): + if group_id is None: + group_id = self.get_group_id_for_group(group_name) + content = self.delete_group_by_id(group_id) + + return content + + def delete_group_by_id(self, group_id): + return self.client.delete_group_by_id(group_id) + + def add_user_to_group(self, group_id, group_name, user_id, user_name): + if group_id is None: + group_id = self.get_group_id_for_group(group_name) + if user_id is None: + user_id = self.get_user_id_for_user(user_name) + + return self.group_operation(op='add', group_id=group_id, user_id=user_id) + + def remove_user_from_group(self, group_id, group_name, user_id, user_name): + if group_id is None: + group_id = self.get_group_id_for_group(group_name) + if user_id is None: + user_id = self.get_user_id_for_user(user_name) + + return self.group_operation(op='remove', group_id=group_id, user_id=[user_id]) + + def group_operation(self, op, group_id, user_id): + return self.update_group_by_id(group_id=group_id, operation=op, values=[user_id]) + + @classmethod + def users_to_user_ids(cls, users): + """ + Convert a list of user names and or user ids to a list of user ids + :param users: list of user names or ids + :return: list of user ids + """ + # we need a list of ids. + return cls.members_to_ids(users, cls.get_user_id_for_user) + + @classmethod + def groups_to_group_ids(cls, groups): + """ + Convert a list of user names and or user ids to a list of user ids + :param groups: list of user names or ids + :return: list of user ids + """ + # we need a list of ids. + return cls.members_to_ids(groups, cls.get_group_id_for_group) + + @classmethod + def filter_active_only(cls, content, active): + resources = content.get('Resources') + if active is not None and resources: + content['Resources'] = [ + resource for resource in resources if resource.get('active') == active + ] + + return content + + @classmethod + def _parse_id_from_json(cls, name, value, filters, data): + if not data: + raise ScimError( + 'Failed to find {} {} using filter {}, no response'.format(name, value, filters)) + + resources = data.get('Resources') + if not resources: + raise ScimError('Failed to find resources in json data for response: {}'.format(data)) + + if len(resources) != 1: + raise ScimError( + 'Expected only 1 resource using filter {} in json data: {}'.format(filters, data)) + + resource = resources[0] + resource_id = resource.get('id') + if not resource_id: + raise ScimError( + 'Expected {} id in resource using filter {} in json data: {}'.format(name, + filters, data)) + + return resource_id + + @classmethod + def _parse_name_from_json(cls, id_value, value, data): + if not data: + raise ScimError('Failed to find {} {}, no response'.format(id_value, value)) + + resources = data.get('Resources') + if not resources: + raise ScimError('Failed to find resources in json data for response: {}'.format(data)) + + resource_name = None + for resource in resources: + if resource.get('id') == id_value: + resource_name = resource.get('displayName') + break + + return resource_name + + @classmethod + def members_to_ids(cls, members, function): + """ + Convert a list of user names and or user ids to a list of user ids + :param members: list of names or ids + :param function: function to get a list of ids for a name + + :return: list of user ids + """ + # we need a list of ids. + list_of_ids = [] + for member in members: + # see if it's a number + if is_int(member): + member_id = member + else: + # we need to get the user_id + member_id = function(member) + + list_of_ids.append(member_id) + + return list_of_ids diff --git a/databricks_cli/scim/cli.py b/databricks_cli/scim/cli.py new file mode 100644 index 00000000..bbb67fd3 --- /dev/null +++ b/databricks_cli/scim/cli.py @@ -0,0 +1,328 @@ +# Databricks CLI +# Copyright 2017 Databricks, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"), except +# that the use of services to which certain application programming +# interfaces (each, an "API") connect requires that the user first obtain +# a license for the use of the APIs from Databricks, Inc. ("Databricks"), +# by creating an account at www.databricks.com and agreeing to either (a) +# the Community Edition Terms of Service, (b) the Databricks Terms of +# Service, or (c) another written agreement between Licensee and Databricks +# for the use of the APIs. +# +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from json import loads as json_loads + +import click + +from databricks_cli.click_types import OneOfOption, OutputClickType, JsonClickType, \ + OptionalOneOfOption +from databricks_cli.configure.config import provide_api_client, profile_option, debug_option +from databricks_cli.scim.api import ScimApi +from databricks_cli.utils import eat_exceptions, CONTEXT_SETTINGS, pretty_format +from databricks_cli.version import print_version_callback, version + +FILTERS_HELP = 'Filters for filtering the list of users: ' + \ + 'https://docs.databricks.com/api/latest/scim.html#filters' +USER_OPTIONS = ['user-id', 'user-name'] +JSON_FILE_OPTIONS = ['json-file', 'json'] +CREATE_USER_OPTIONS = JSON_FILE_OPTIONS + ['user-name'] + +GROUP_OPTIONS = ['group-id', 'group-name'] + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='List users using the scim api') +@click.option('--filters', default=None, required=False, help=FILTERS_HELP) +@click.option('--output', default=None, help=OutputClickType.help, type=OutputClickType()) +# having an active flag would be really nice. +@click.option('--active/--inactive', default=None, required=False, + help="display only active or inactive users") +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +# pylint:disable=unused-argument +def list_users_cli(api_client, filters, active, output): + content = ScimApi(api_client).list_users(filters=filters, active=active) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Get user information by id or name') +@click.option('--user-id', metavar='', cls=OneOfOption, default=None, one_of=USER_OPTIONS) +@click.option('--user-name', metavar='', cls=OneOfOption, default=None, one_of=USER_OPTIONS) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def get_user_cli(api_client, user_id, user_name): + content = ScimApi(api_client).get_user(user_id=user_id, user_name=user_name) + click.echo(pretty_format(content)) + + +def validate_user_params(json_file, json, user_name, groups, entitlements, roles): + if not bool(user_name) and (bool(groups) or bool(entitlements) or bool(roles)): + raise RuntimeError('Either --user-name or --json-file or --json should be provided') + + if not bool(user_name) and not bool(json_file) ^ bool(json): + raise RuntimeError('Either --user-name or --json-file or --json should be provided') + + if bool(user_name) and (bool(json_file) or bool(json)): + raise RuntimeError('Either --user-name or --json-file or --json should be provided') + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Create a user') +@click.option('--json-file', cls=OneOfOption, default=None, type=click.Path(), + one_of=CREATE_USER_OPTIONS, + help='File containing JSON request to POST to /api/2.0/preview/scim/v2/Users.') +@click.option('--json', cls=OneOfOption, default=None, type=JsonClickType(), + one_of=CREATE_USER_OPTIONS, + help=JsonClickType.help('/api/2.0/preview/scim/v2/Users')) +@click.option('--user-name', metavar='', cls=OneOfOption, default=None, + one_of=CREATE_USER_OPTIONS) +@click.option('--groups', metavar='', is_flag=False, default=None, type=click.STRING, + help='Groups the user should be a member of', multiple=True, required=False) +@click.option('--entitlements', metavar='', is_flag=False, default=None, type=click.STRING, + help='Entitlements the user should have', multiple=True, required=False) +@click.option('--roles', metavar='', is_flag=False, default=None, type=click.STRING, + help='Roles the user should have', multiple=True, required=False) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def create_user_cli(api_client, json_file, json, user_name, groups, entitlements, roles): + validate_user_params(json_file, json, user_name, groups, entitlements, roles) + + client = ScimApi(api_client) + if user_name: + content = client.create_user(user_name=user_name, groups=groups, entitlements=entitlements, + roles=roles) + else: + if json_file: + with open(json_file, 'r') as f: + json = f.read() + deser_json = json_loads(json) + content = client.create_user_json(json=deser_json) + + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='NOT IMPLEMENTED') +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +# pylint:disable=unused-argument +def update_user_by_id_cli(api_client, user_id, operation, path, values): + content = ScimApi(api_client) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='NOT IMPLEMENTED') +@click.option("--force", required=False, default=False) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +# pylint:disable=unused-argument +def overwrite_user_by_id_cli(api_client, user_id, user_name, groups, entitlements, roles): + content = ScimApi(api_client) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Delete a user. User is retained for 30 days then purged.') +@click.option('--user-id', metavar='', cls=OneOfOption, default=None, one_of=USER_OPTIONS) +@click.option('--user-name', metavar='', cls=OneOfOption, default=None, one_of=USER_OPTIONS) +@click.option("--force", required=False, default=False) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def delete_user_cli(api_client, user_id, user_name, force): + if not force and not click.confirm( + 'Do you want to remove "{}"'.format(user_name)): + click.echo('Not removing "{}"'.format(user_name)) + return + + click.echo('REMOVING "{}"'.format(user_name)) + content = ScimApi(api_client).delete_user(user_id=user_id, user_name=user_name) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='List groups') +@click.option('--filters', default=None, required=False, help=FILTERS_HELP) +@click.option('--group-id', metavar='', cls=OptionalOneOfOption, default=None, + one_of=GROUP_OPTIONS) +@click.option('--group-name', metavar='', cls=OptionalOneOfOption, default=None, + one_of=GROUP_OPTIONS) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def groups_list_cli(api_client, filters, group_id, group_name): + content = ScimApi(api_client).list_groups(filters=filters, group_id=group_id, + group_name=group_name) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Get group information by id or name') +@click.option('--group-id', metavar='', cls=OneOfOption, default=None, one_of=GROUP_OPTIONS) +@click.option('--group-name', metavar='', cls=OneOfOption, default=None, + one_of=GROUP_OPTIONS) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def groups_get_cli(api_client, group_id, group_name): + content = ScimApi(api_client).get_group(group_id=group_id, group_name=group_name) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Create a group') +@click.option('--group-name', required=True) +@click.option('--member', 'members', is_flag=False, default=None, metavar='', + type=click.STRING, + help='members to add to the group', multiple=True, required=False) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def groups_create_cli(api_client, group_name, members): + content = ScimApi(api_client).create_group(group_name, members) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='NOT IMPLEMENTED') +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +# pylint:disable=unused-argument +def groups_update_cli(api_client, group_id, operation, path, values): + content = ScimApi(api_client) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Delete a group.') +@click.option('--group-id', metavar='', cls=OneOfOption, default=None, one_of=GROUP_OPTIONS) +@click.option('--group-name', metavar='', cls=OneOfOption, default=None, + one_of=GROUP_OPTIONS) +@click.option("--force", required=False, default=False) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def groups_delete_cli(api_client, group_id, group_name): + content = ScimApi(api_client).delete_group(group_id=group_id, group_name=group_name) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Add a user to a group') +@click.option('--group-id', metavar='', cls=OneOfOption, default=None, one_of=GROUP_OPTIONS) +@click.option('--group-name', metavar='', cls=OneOfOption, default=None, + one_of=GROUP_OPTIONS) +@click.option('--user-id', metavar='', cls=OneOfOption, default=None, one_of=USER_OPTIONS) +@click.option('--user-name', metavar='', cls=OneOfOption, default=None, one_of=USER_OPTIONS) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def groups_add_user_cli(api_client, group_id, group_name, user_id, user_name): + content = ScimApi(api_client).add_user_to_group(group_id=group_id, group_name=group_name, + user_id=user_id, + user_name=user_name) + click.echo(pretty_format(content)) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Remove a user from a group.') +@click.option('--group-id', metavar='', cls=OneOfOption, default=None, one_of=GROUP_OPTIONS) +@click.option('--group-name', metavar='', cls=OneOfOption, default=None, + one_of=GROUP_OPTIONS) +@click.option('--user-id', metavar='', cls=OneOfOption, default=None, one_of=USER_OPTIONS) +@click.option('--user-name', metavar='', cls=OneOfOption, default=None, one_of=USER_OPTIONS) +@click.option("--force", required=False, default=False) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def groups_remove_user_cli(api_client, group_id, group_name, user_id, user_name, force): + if not force and not click.confirm( + 'Do you want to remove "{}" from group "{}"'.format(user_name, group_name)): + click.echo('Not removing "{}" from group "{}"'.format(user_name, group_name)) + return + + click.echo('REMOVING "{}" from group "{}"'.format(user_name, group_name)) + content = ScimApi(api_client).remove_user_from_group(group_id=group_id, group_name=group_name, + user_id=user_id, + user_name=user_name) + click.echo(pretty_format(content)) + + +@click.group(context_settings=CONTEXT_SETTINGS, + short_help='Utility to interact with Databricks scim api.\n') +@click.option('--version', '-v', is_flag=True, callback=print_version_callback, + expose_value=False, is_eager=True, help=version) +@debug_option +@profile_option +@eat_exceptions +def scim_group(): + """Provide utility to interact with Databricks scim api.""" + pass + + +@click.group(context_settings=CONTEXT_SETTINGS, + short_help='Utility to interact with Databricks scim api.\n') +@click.option('--version', '-v', is_flag=True, callback=print_version_callback, + expose_value=False, is_eager=True, help=version) +@debug_option +@profile_option +@eat_exceptions +def scim_groups_group(): + """Provide utility to interact with Databricks scim groups api.""" + pass + + +scim_group.add_command(list_users_cli, name='list-users') +scim_group.add_command(get_user_cli, name='get-user') +scim_group.add_command(create_user_cli, name='create-user') +scim_group.add_command(update_user_by_id_cli, name='update-user') +scim_group.add_command(overwrite_user_by_id_cli, name='overwrite-user') +scim_group.add_command(delete_user_cli, name='delete-user') +scim_group.add_command(scim_groups_group, name='groups') + +# add the aliases +scim_group.add_command(groups_list_cli, name='list-groups') +scim_group.add_command(groups_get_cli, name='get-group') +scim_group.add_command(groups_create_cli, name='create-group') +scim_group.add_command(groups_update_cli, name='update-group') +scim_group.add_command(groups_delete_cli, name='delete-group') + +scim_groups_group.add_command(groups_list_cli, name='list') +scim_groups_group.add_command(groups_get_cli, name='get') +scim_groups_group.add_command(groups_create_cli, name='create') +scim_groups_group.add_command(groups_add_user_cli, name='add-user') +scim_groups_group.add_command(groups_remove_user_cli, name='remove-user') +scim_groups_group.add_command(groups_delete_cli, name='delete') +# scim_groups_group.add_command(group_add_user_cli, name='delete') diff --git a/databricks_cli/sdk/__init__.py b/databricks_cli/sdk/__init__.py index 2e42a61c..dbd63536 100644 --- a/databricks_cli/sdk/__init__.py +++ b/databricks_cli/sdk/__init__.py @@ -52,4 +52,5 @@ help(JobsService) """ from .service import * +from .scim_service import * from .api_client import ApiClient diff --git a/databricks_cli/sdk/api_client.py b/databricks_cli/sdk/api_client.py index d5870a6a..5fa06eb6 100644 --- a/databricks_cli/sdk/api_client.py +++ b/databricks_cli/sdk/api_client.py @@ -30,6 +30,7 @@ import base64 import json +import logging import warnings import requests import ssl @@ -127,6 +128,12 @@ def perform_query(self, method, path, data = {}, headers = None): except ValueError: pass raise requests.exceptions.HTTPError(message, response=e.response) + + if method.lower() == 'delete' and not resp.content: + # it is possible for delete to return a completely empty body. + # If it does, return an empty dict rather than have resp.json throw. + return {} + return resp.json() diff --git a/databricks_cli/sdk/scim_service.py b/databricks_cli/sdk/scim_service.py new file mode 100644 index 00000000..a1fa3114 --- /dev/null +++ b/databricks_cli/sdk/scim_service.py @@ -0,0 +1,178 @@ +# +# Databricks CLI +# Copyright 2020 Databricks, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"), except +# that the use of services to which certain application programming +# interfaces (each, an "API") connect requires that the user first obtain +# a license for the use of the APIs from Databricks, Inc. ("Databricks"), +# by creating an account at www.databricks.com and agreeing to either (a) +# the Community Edition Terms of Service, (b) the Databricks Terms of +# Service, or (c) another written agreement between Licensee and Databricks +# for the use of the APIs. +# +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +class ScimService(object): + ACCEPT_TYPE = 'application/scim+json' + PREVIEW_BASE = '/preview/scim/v2/' + + FILTERS = { + # Operator: Description, # Behavior + "eq": "equals", # Attribute and operator values must be identical. + "ne": "not_equal", # equal to Attribute and operator values are not identical. + "co": "contains", # Operator value must be a substring of attribute value. + "sw": "starts_with", # with Attribute must start with and contain operator value. + "and": "and", # AND Match when all expressions evaluate to true. + "or": "or", # OR Match when any expression evaluates to true. + } + + def __init__(self, client): + self.client = client + + @classmethod + def _add_accept(cls, headers): + if headers is None: + headers = {} + headers.update({'Accept': 'application/scim+json'}) + return headers + + def users(self, filters=None, headers=None): + headers = self._add_accept(headers) + if filters is not None: + filters = '?filter=' + filters + else: + filters = '' + return self.client.perform_query('GET', '{}Users{}'.format(self.PREVIEW_BASE, filters), headers=headers) + + def user_by_id(self, user_id, headers=None): + headers = self._add_accept(headers) + return self.client.perform_query('GET', '{}Users/{}'.format(self.PREVIEW_BASE, user_id), headers=headers) + + @classmethod + def _update_user_data(cls, _data, groups, entitlements, roles): + + if groups is not None: + _data.update({'groups': cls.ids_list_to_values_list(groups)}) + if entitlements is not None: + _data.update({'entitlements': cls.ids_list_to_values_list(entitlements)}) + if roles is not None: + _data.update({'roles': cls.ids_list_to_values_list(roles)}) + + return _data + + def create_user(self, user_name, groups, entitlements, roles, headers=None): + headers = self._add_accept(headers) + _data = self._update_user_data({ + 'schemas': [ + 'urn:ietf:params:scim:schemas:core:2.0:User' + ], + 'userName': user_name, + }, groups=groups, entitlements=entitlements, roles=roles) + + return self.create_user_json(_data, headers) + + def create_user_json(self, data, headers=None): + headers = self._add_accept(headers) + return self.client.perform_query('POST', '{}Users'.format(self.PREVIEW_BASE), data=data, headers=headers) + + def update_user_by_id(self, user_id, operation, path, values, headers=None): + headers = self._add_accept(headers) + _data = { + 'schemas': [ + 'urn:ietf:params:scim:api:messages:2.0:PatchOp' + ], + 'op': operation, + 'path': path, + 'value': self.ids_list_to_values_list(values) + } + return self.client.perform_query('PATCH', '{}Users/{}'.format(self.PREVIEW_BASE, user_id), data=_data, + headers=headers) + + def overwrite_user_by_id(self, user_id, user_name, groups, entitlements, roles, headers=None): + headers = self._add_accept(headers) + + _data = self._update_user_data({ + 'schemas': [ + 'urn:ietf:params:scim:schemas:core:2.0:User' + ], + 'userName': user_name, + }, groups=groups, entitlements=entitlements, roles=roles) + + return self.client.perform_query('PUT', '{}Users/{}'.format(self.PREVIEW_BASE, user_id), data=_data, + headers=headers) + + def delete_user_by_id(self, user_id, headers=None): + headers = self._add_accept(headers) + return self.client.perform_query('DELETE', '{}Users/{}'.format(self.PREVIEW_BASE, user_id), headers=headers) + + def groups(self, filters=None, headers=None): + headers = self._add_accept(headers) + if filters is not None: + if isinstance(filters, list): + filters = ' '.join(filters) + filters = '?filter=' + filters + else: + filters = '' + return self.client.perform_query('GET', '{}Groups{}'.format(self.PREVIEW_BASE, filters), headers=headers) + + def group_by_id(self, group_id, headers=None): + headers = self._add_accept(headers) + return self.client.perform_query('GET', '{}Groups/{}'.format(self.PREVIEW_BASE, group_id), headers=headers) + + def create_group_internal(self, group_name, members, headers=None): + headers = self._add_accept(headers) + + _data = { + 'schemas': [ + 'urn:ietf:params:scim:schemas:core:2.0:Group' + ], + 'displayName': group_name, + # members is a list of ids, we need to convert it to a list of hashes. + 'members': self.ids_list_to_values_list(members) + } + + return self.client.perform_query('POST', '{}Groups'.format(self.PREVIEW_BASE), data=_data, headers=headers) + + def update_group_by_id(self, group_id, operation, values, headers=None): + headers = self._add_accept(headers) + + operation_data = { + 'op': operation, + 'value': { + 'members': self.ids_list_to_values_list(values) + } + } + + # https://help.databricks.com/hc/en-us/requests/24702 remove requires a path + if operation == 'remove': + operation_data['path'] = 'members' + + _data = { + 'schemas': [ + 'urn:ietf:params:scim:api:messages:2.0:PatchOp' + ], + 'Operations': [operation_data] + } + + return self.client.perform_query('PATCH', '{}Groups/{}'.format(self.PREVIEW_BASE, group_id), data=_data, + headers=headers) + + def delete_group_by_id(self, group_id, headers=None): + headers = self._add_accept(headers) + return self.client.perform_query('DELETE', '{}Groups/{}'.format(self.PREVIEW_BASE, group_id), headers=headers) + + @classmethod + def ids_list_to_values_list(cls, ids_list): + return [{'value': id_value} for id_value in ids_list] diff --git a/databricks_cli/utils.py b/databricks_cli/utils.py index f92a351c..e6a4fe50 100644 --- a/databricks_cli/utils.py +++ b/databricks_cli/utils.py @@ -1,3 +1,4 @@ + # Databricks CLI # Copyright 2017 Databricks, Inc. # @@ -21,6 +22,7 @@ # See the License for the specific language governing permissions and # limitations under the License. + import sys import traceback from json import dumps as json_dumps, loads as json_loads @@ -86,6 +88,12 @@ def decorator(*args, **kwargs): return decorator +def is_debug_mode(): + ctx = click.get_current_context() + context_object = ctx.ensure_object(ContextObject) + return context_object.debug_mode + + def error_and_quit(message): ctx = click.get_current_context() context_object = ctx.ensure_object(ContextObject) @@ -132,3 +140,12 @@ def for_profile(profile): 'Please configure by entering ' '`{argv} configure --profile {profile}`').format( profile=profile, argv=sys.argv[0])) + + +def is_int(s): + # type: (str) -> bool + try: + int(s) + return True + except ValueError: + return False diff --git a/tests/scim/test_user_args.py b/tests/scim/test_user_args.py new file mode 100644 index 00000000..ac51e374 --- /dev/null +++ b/tests/scim/test_user_args.py @@ -0,0 +1,47 @@ +# +# Databricks CLI +# Copyright 2020 Databricks, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"), except +# that the use of services to which certain application programming +# interfaces (each, an "API") connect requires that the user first obtain +# a license for the use of the APIs from Databricks, Inc. ("Databricks"), +# by creating an account at www.databricks.com and agreeing to either (a) +# the Community Edition Terms of Service, (b) the Databricks Terms of +# Service, or (c) another written agreement between Licensee and Databricks +# for the use of the APIs. +# +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import pytest + +from databricks_cli.scim.cli import validate_user_params + + +@pytest.mark.parametrize('json_file, json, user_name, groups, entitlements, roles, should_fail', [ + # json_file, json, user_name, groups, entitlements, roles, should_fail + [None, None, None, None, None, None, True], + [None, None, 'ac', None, None, None, False], + [None, '{}', None, None, None, None, False], + [None, '{}', 'ac', None, None, None, True], + ['so', None, None, None, None, None, False], + ['so', None, 'ac', None, None, None, True], + ['so', '{}', None, None, None, None, True], + ['so', '{}', 'ac', None, None, None, True], + +]) +def test_user_args(json_file, json, user_name, groups, entitlements, roles, should_fail): + if should_fail: + with pytest.raises(RuntimeError): + validate_user_params(json_file, json, user_name, groups, entitlements, roles) + else: + validate_user_params(json_file, json, user_name, groups, entitlements, roles)