diff --git a/databricks_cli/cli.py b/databricks_cli/cli.py index 0b0a8f1b..3d12f4a1 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.permissions.cli import permissions_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 @@ -65,6 +66,7 @@ def cli(): cli.add_command(tokens_group, name='tokens') cli.add_command(instance_pools_group, name="instance-pools") cli.add_command(pipelines_group, name='pipelines') +cli.add_command(permissions_group, name='permissions') if __name__ == "__main__": cli() diff --git a/databricks_cli/click_types.py b/databricks_cli/click_types.py index ff508a74..074f803d 100644 --- a/databricks_cli/click_types.py +++ b/databricks_cli/click_types.py @@ -114,7 +114,7 @@ def __init__(self, *args, **kwargs): def handle_parse_result(self, ctx, opts, args): cleaned_opts = set([o.replace('_', '-') for o in opts.keys()]) if len(cleaned_opts.intersection(set(self.one_of))) == 0: - raise MissingParameter('One of {} must be provided.'.format(self.one_of)) + raise MissingParameter('One of {} must be provided.'.format(self.one_of), param=self) if len(cleaned_opts.intersection(set(self.one_of))) > 1: raise UsageError('Only one of {} should be provided.'.format(self.one_of)) return super(OneOfOption, self).handle_parse_result(ctx, opts, args) @@ -124,6 +124,7 @@ class OptionalOneOfOption(Option): def __init__(self, *args, **kwargs): self.one_of = kwargs.pop('one_of') super(OptionalOneOfOption, self).__init__(*args, **kwargs) + self.param_hint = self.one_of def handle_parse_result(self, ctx, opts, args): cleaned_opts = set([o.replace('_', '-') for o in opts.keys()]) diff --git a/databricks_cli/permissions/__init__.py b/databricks_cli/permissions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/databricks_cli/permissions/api.py b/databricks_cli/permissions/api.py new file mode 100644 index 00000000..fe437781 --- /dev/null +++ b/databricks_cli/permissions/api.py @@ -0,0 +1,343 @@ +# 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 enum import Enum + +from databricks_cli.sdk.permissions_service import PermissionsService +from .exceptions import PermissionsError + + +class PermissionTargets(Enum): + clusters = 'clusters' + cluster = clusters + directories = 'directories' + directory = directories + instance_pools = 'instance-pools' + instance_pool = instance_pools + jobs = 'jobs' + job = jobs + notebooks = 'notebooks' + notebook = notebooks + registered_models = 'registered-models' + registered_model = registered_models + model = registered_models + models = registered_models + + @classmethod + def values(cls): + return [e.value for e in PermissionTargets] + + @classmethod + def help_values(cls): + return ', '.join([e.value for e in PermissionTargets]) + + @classmethod + def get(cls, item): + if '-' in item: + item = item.replace('-', '_') + return PermissionTargets[item] + + +class PermissionLevel(Enum): + no_permissions = 'NONE' + manage = 'CAN_MANAGE' + manage_staging_versions = 'CAN_MANAGE_STAGING_VERSIONS' + manage_production_versions = 'CAN_MANAGE_PRODUCTION_VERSIONS' + restart = 'CAN_RESTART' + attach = 'CAN_ATTACH_TO' + manage_run = 'CAN_MANAGE_RUN' + owner = 'IS_OWNER' + view = 'CAN_VIEW' + read = 'CAN_READ' + run = 'CAN_RUN' + edit = 'CAN_EDIT' + use = 'CAN_USE' + + @classmethod + def names(cls): + return [e.name for e in PermissionLevel] + + @classmethod + def values(cls): + return [e.value for e in PermissionLevel] + + @classmethod + def help_values(cls): + return ', '.join([e.value for e in PermissionLevel]) + + +class BasicPermissions(object): + def __init__(self, object_type, valid_permissions): + self.object_type = object_type + self.valid_permissions = valid_permissions + + def is_valid_target(self, permission): + # type: (str) -> bool + return permission in self.valid_permissions + + def valid_targets(self): + return [s.name for s in self.valid_permissions] + + +class TokenPermissions(BasicPermissions): + def __init__(self): + super().__init__(PermissionTargets.token, { + PermissionLevel.no_permissions, + PermissionLevel.use, + PermissionLevel.manage, + }) + + +class PasswordPermissions(BasicPermissions): + def __init__(self): + super().__init__(PermissionTargets.password, { + PermissionLevel.no_permissions, + PermissionLevel.use, + }) + + +class ClusterPermissions(BasicPermissions): + def __init__(self): + super().__init__(PermissionTargets.clusters, { + PermissionLevel.no_permissions, + PermissionLevel.attach, + PermissionLevel.restart, + PermissionLevel.manage, + }) + + +class InstancePoolPermissions(BasicPermissions): + def __init__(self): + super().__init__(PermissionTargets.instance_pools, { + PermissionLevel.no_permissions, + PermissionLevel.attach, + PermissionLevel.manage, + }) + + +class JobPermissions(BasicPermissions): + def __init__(self): + super().__init__(PermissionTargets.jobs, { + PermissionLevel.no_permissions, + PermissionLevel.view, + PermissionLevel.manage_run, + PermissionLevel.owner, + PermissionLevel.manage, + }) + + +class NotebookPermissions(BasicPermissions): + def __init__(self): + super().__init__(PermissionTargets.notebook, { + PermissionLevel.no_permissions, + PermissionLevel.read, + PermissionLevel.run, + PermissionLevel.edit, + PermissionLevel.manage, + }) + + +class DirectoryPermissions(BasicPermissions): + def __init__(self): + super().__init__(PermissionTargets.directory, { + PermissionLevel.no_permissions, + PermissionLevel.read, + PermissionLevel.run, + PermissionLevel.edit, + PermissionLevel.manage, + }) + + +class MlFlowPermissions(BasicPermissions): + def __init__(self): + super().__init__(PermissionTargets.models, { + PermissionLevel.no_permissions, + PermissionLevel.read, + PermissionLevel.edit, + PermissionLevel.manage_staging_versions, + PermissionLevel.manage_production_versions, + PermissionLevel.manage, + }) + + +class PermissionType(Enum): + user = 'user_name' + group = 'group_name' + service = 'service_principal_name' + + @classmethod + def values(cls): + return [e.value for e in PermissionType] + + +class PermissionsLookup(object): + """ + static lookup table for permissions + """ + + items = { + 'CAN_MANAGE': PermissionLevel.manage, + 'CAN_RESTART': PermissionLevel.restart, + 'CAN_ATTACH_TO': PermissionLevel.attach, + 'CAN_MANAGE_RUN': PermissionLevel.manage_run, + 'IS_OWNER': PermissionLevel.owner, + 'CAN_VIEW': PermissionLevel.view, + 'CAN_READ': PermissionLevel.read, + 'CAN_RUN': PermissionLevel.run, + 'CAN_EDIT': PermissionLevel.edit, + 'user_name': PermissionType.user, + 'group_name': PermissionType.group, + 'service_principal_name': PermissionType.service, + + 'clusters': ClusterPermissions(), + 'cluster': ClusterPermissions(), + 'directories': DirectoryPermissions(), + 'directory': DirectoryPermissions(), + 'instance-pools': InstancePoolPermissions(), + 'instance_pools': InstancePoolPermissions(), + 'jobs': JobPermissions(), + 'job': JobPermissions(), + 'notebooks': NotebookPermissions(), + 'notebook': NotebookPermissions(), + 'registered-models': MlFlowPermissions(), + 'registered_models': MlFlowPermissions(), + 'model': MlFlowPermissions(), + 'models': MlFlowPermissions(), + } + + +class Permission(object): + def __init__(self, object_type, permission_type, permission_level, permission_value): + # type: (str, PermissionType, str, str) -> None + self.validator = PermissionsLookup.items[object_type] + + if not self.validator.is_valid_target(permission_level): + raise PermissionsError( + '{} is not a valid target for {}\n'.format(permission_level, + self.validator.object_type) + + + 'Valid values are {}'.format(self.validator.valid_targets())) + + self.permission_type = permission_type + self.permission_level = PermissionLevel[permission_level] + self.value = permission_value + + def to_dict(self): + # type: () -> dict + if not self.permission_type or not self.permission_level: + return {} + + return { + self.permission_type.value: self.value, + 'permission_level': self.permission_level.value + } + + +class PermissionsObject(object): + def __init__(self, permissions=None): + if not permissions: + permissions = [] + self.permissions = permissions + + def add(self, permission): + # type: (Permission) -> None + self.permissions.append(permission) + + def user(self, name, level): + # type: (str, PermissionLevel) -> None + self.add(Permission(PermissionType.user, value=name, permission_level=level)) + + def group(self, name, level): + # type: (str, PermissionLevel) -> None + self.add(Permission(PermissionType.group, value=name, permission_level=level)) + + def service(self, name, level): + # type: (str, PermissionLevel) -> None + self.add(Permission(PermissionType.service, value=name, permission_level=level)) + + def to_dict(self): + # type: () -> dict + if not self.permissions: + return {} + + return { + 'access_control_list': [entry.to_dict() for entry in self.permissions] + } + + def check_if_valid_for(self, object_type): + """ + Check if the permissions are valid for this object type. + """ + pass + + +# FIXME: add set/update permissions, right now this is read only. +class PermissionsApi(object): + def __init__(self, api_client): + self.api_client = api_client + self.client = PermissionsService(api_client) + + def get_permissions(self, object_type, object_id): + # type: (str, str) -> dict + if not object_type: + raise PermissionsError('object_type is invalid') + + if not object_id: + object_id = '' + # raise PermissionsError('object_id is invalid') + + return self.client.get_permissions(object_type=PermissionTargets.get(object_type).value, + object_id=object_id) + + def get_possible_permissions(self, object_type, object_id): + # type: (str, str) -> dict + if not object_type: + raise PermissionsError('object_type is invalid') + + if not object_id: + raise PermissionsError('object_id is invalid') + + return self.client.get_possible_permissions( + object_type=PermissionTargets.get(object_type).value, + object_id=object_id) + + def add_permissions(self, object_type, object_id, permissions): + # type: (str, str, PermissionsObject) -> dict + if not object_type: + raise PermissionsError('object_type is invalid') + + if not object_id: + raise PermissionsError('object_id is invalid') + + return self.client.add_permissions(object_type=PermissionTargets.get(object_type).value, + object_id=object_id, data=permissions.to_dict()) + + def update_permissions(self, object_type, object_id, permissions): + # type: (str, str, PermissionsObject) -> dict + if not object_type: + raise PermissionsError('object_type is invalid') + + if not object_id: + raise PermissionsError('object_id is invalid') + + return self.client.update_permissions(object_type=PermissionTargets.get(object_type).value, + object_id=object_id, data=permissions.to_dict()) diff --git a/databricks_cli/permissions/cli.py b/databricks_cli/permissions/cli.py new file mode 100644 index 00000000..5601cf2b --- /dev/null +++ b/databricks_cli/permissions/cli.py @@ -0,0 +1,178 @@ +# 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. +import json + +import click +from click import UsageError + +from databricks_cli.click_types import OneOfOption +from databricks_cli.configure.config import provide_api_client, profile_option, debug_option +from databricks_cli.permissions.api import PermissionsApi, PermissionTargets, PermissionLevel, \ + PermissionType, Permission, PermissionsObject +from databricks_cli.utils import eat_exceptions, CONTEXT_SETTINGS +from databricks_cli.utils import pretty_format +from databricks_cli.version import print_version_callback, version +from databricks_cli.workspace.api import WorkspaceApi + +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_USER_SERVICE_OPTIONS = ['group-name', 'user-name', 'service-name'] +PERMISSION_LEVEL_OPTIONS = PermissionLevel.names() +POSSIBLE_OBJECT_TYPES = 'Possible object types are: \n\t{}\n'.format( + PermissionTargets.help_values()) + +POSSIBLE_PERMISSION_LEVELS = 'Possible permission levels are: \n\t{}\n'.format( + PermissionLevel.help_values()) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Get permissions for an item. ' + POSSIBLE_OBJECT_TYPES) +@click.option('--object-type', required=True, help=POSSIBLE_OBJECT_TYPES) +@click.option('--object-id', required=True, help='object id to require permission about') +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def get_cli(api_client, object_type, object_id): + perms_api = PermissionsApi(api_client) + click.echo(pretty_format(perms_api.get_permissions(object_type, object_id))) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='List permission types') +@click.option('--object-type', required=True, help=POSSIBLE_OBJECT_TYPES) +@click.option('--object-id', required=True, help='object id to require permission about') +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def list_permissions_types_cli(api_client, object_type, object_id): + perms_api = PermissionsApi(api_client) + click.echo(pretty_format(perms_api.get_possible_permissions(object_type, object_id))) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Add or modify permission types') +@click.option('--object-type', required=True, help=POSSIBLE_OBJECT_TYPES) +@click.option('--object-id', required=True, help='object id to require permission about') +@click.option('--group-name', metavar='', cls=OneOfOption, + one_of=GROUP_USER_SERVICE_OPTIONS) +@click.option('--user-name', metavar='', cls=OneOfOption, one_of=GROUP_USER_SERVICE_OPTIONS) +@click.option('--service-name', metavar='', cls=OneOfOption, + one_of=GROUP_USER_SERVICE_OPTIONS) +@click.option('--permission-level', metavar='', type=click.Choice(PermissionLevel.names()), + required=True, help=POSSIBLE_PERMISSION_LEVELS) +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def add_cli(api_client, object_type, object_id, user_name, group_name, service_name, + permission_level): + perms_api = PermissionsApi(api_client) + + # Determine the type of permissions we're adding. + if user_name: + perm_type = PermissionType.user + value = user_name + elif group_name: + perm_type = PermissionType.group + value = group_name + elif service_name: + perm_type = PermissionType.service + value = service_name + else: + # hanging if. This shouldn't be hit, because OneOfOption should prevent it. + # this else/raise is for readability when doing a review. + raise UsageError('Invalid argument') + + permission = Permission(object_type, perm_type, permission_level, value) + all_permissions = PermissionsObject([permission]) + + all_permissions.check_if_valid_for(object_type) + + click.echo(pretty_format(perms_api.add_permissions(object_type, object_id, all_permissions))) + + +@click.command(context_settings=CONTEXT_SETTINGS) +@debug_option +@profile_option +@eat_exceptions +def list_permissions_targets_cli(): + click.echo(POSSIBLE_OBJECT_TYPES) + + +@click.command(context_settings=CONTEXT_SETTINGS) +@debug_option +@profile_option +@eat_exceptions +def list_permissions_level_cli(): + click.echo(POSSIBLE_PERMISSION_LEVELS) + + +@click.command(context_settings=CONTEXT_SETTINGS, + short_help='Get permissions for a directory') +@click.option('--path', required=True, help='Path in the workspace for to get permissions for.') +@debug_option +@profile_option +@eat_exceptions +@provide_api_client +def directory_cli(api_client, path): + perms_api = PermissionsApi(api_client) + workspace_api = WorkspaceApi(api_client) + + object_type = 'directories' + object_ids = workspace_api.get_id_for_directory(path) + + if not object_ids: + click.echo('Failed to find id for {}'.format(path)) + return + + for object_id in object_ids: + click.echo(pretty_format(perms_api.get_permissions(object_type, object_id))) + + +@click.group(context_settings=CONTEXT_SETTINGS, + help='Utility to interact with Databricks permissions api.\n\n' + + 'Valid object types for --object-type are:\n\n\t' + + json.dumps(PermissionTargets.values()) + ) +@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 permissions_group(): # NOQA + # A python doc comment here will override the hand coded help above. + pass + + +permissions_group.add_command(add_cli, name='add') +permissions_group.add_command(directory_cli, name='ls') +permissions_group.add_command(get_cli, name='get') +permissions_group.add_command(list_permissions_level_cli, name='levels') +permissions_group.add_command(list_permissions_targets_cli, name='targets') +permissions_group.add_command(list_permissions_types_cli, name='list-types') diff --git a/databricks_cli/permissions/exceptions.py b/databricks_cli/permissions/exceptions.py new file mode 100644 index 00000000..c986acb6 --- /dev/null +++ b/databricks_cli/permissions/exceptions.py @@ -0,0 +1,26 @@ +# Databricks CLI +# Copyright 2018 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 PermissionsError(Exception): + pass diff --git a/databricks_cli/sdk/permissions_service.py b/databricks_cli/sdk/permissions_service.py new file mode 100644 index 00000000..a95fbb3c --- /dev/null +++ b/databricks_cli/sdk/permissions_service.py @@ -0,0 +1,95 @@ +from typing import Optional + +from databricks_cli.sdk.preview_service import PreviewService + + +class PermissionsService(PreviewService): + def __init__(self, client): + super(PermissionsService, self).__init__('permissions') + self.client = client + + def create_url(self, object_type, object_id, suffix=''): + # type: (str, str, str) -> str + return '{base}/{object_type}/{object_id}{suffix}'.format(base=self.url_base, + object_type=object_type, + object_id=object_id, suffix=suffix) + + def get_permissions(self, object_type, object_id, headers=None): + # type: (str, str, Optional[dict]) -> dict + """ + Get the permissions for an object type and id + + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/get-tokens-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/get-passwords-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/get-cluster-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/get-instance-pool-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/get-job-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/get-notebook-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/get-directory-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/get-registered-model-permissions + """ + + return self.client.perform_query('GET', self.create_url(object_type=object_type, + object_id=object_id), + headers=headers) + + def get_possible_permissions(self, object_type, object_id, headers=None): + # type: (str, str, Optional[dict]) -> dict + """ + Get the permission levels for an object type. + + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/get-tokens-permission-levels + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/get-password-permission-levels + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/get-clusters-permission-levels + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/get-instance-pools-permission-levels + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/get-jobs-permission-levels + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/get-notebooks-permission-levels + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/get-directories-permission-levels + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/get-registered-models-permission-levels + """ + + return self.client.perform_query('GET', self.create_url(object_type=object_type, + object_id=object_id, + suffix='/permissionLevels'), + headers=headers) + + def add_permissions(self, object_type, object_id, data, headers=None): + # type: (str, str, dict, Optional[dict]) -> dict + """ + Add permissions, this does not REMOVE. + A remove requires an update_permissions call to complete replacement. + + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/set-tokens-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/set-password-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/set-cluster-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/set-instance-pool-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/set-job-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/set-notebook-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/set-directory-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/set-registered-model-permissions + """ + + return self.client.perform_query('PATCH', self.create_url(object_type, object_id), + data=data, + headers=headers) + + def update_permissions(self, object_type, object_id, data, headers=None): + # type: (str, str, dict, Optional[dict]) -> dict + """ + Update/Overwrite all permissions + This overwrites all of the permissions for an object. + This is how you remove permissions, you call update with a complete set of permissions. + + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/update-tokens-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/update-all-password-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/update-all-cluster-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/update-all-instance-pool-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/update-all-job-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/update-all-notebook-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/update-all-directory-permissions + https://docs.databricks.com/dev-tools/api/latest/permissions.html#operation/update-registered-model-permissions + """ + + return self.client.perform_query('PUT', self.create_url(object_type, object_id), + data=data, + headers=headers) diff --git a/databricks_cli/sdk/preview_service.py b/databricks_cli/sdk/preview_service.py new file mode 100644 index 00000000..8ec41849 --- /dev/null +++ b/databricks_cli/sdk/preview_service.py @@ -0,0 +1,11 @@ +class PreviewService(object): + """ + This class makes it easier to create preview endpoints. + """ + PREVIEW_BASE = '/preview/' + + def __init__(self, base_url): + self.url_base = self.create_preview_url(base_url) + + def create_preview_url(self, base_url): + return self.PREVIEW_BASE + base_url diff --git a/databricks_cli/workspace/api.py b/databricks_cli/workspace/api.py index 571abed5..83ac5dec 100644 --- a/databricks_cli/workspace/api.py +++ b/databricks_cli/workspace/api.py @@ -23,6 +23,7 @@ import os from base64 import b64encode, b64decode +from os.path import dirname import click from requests.exceptions import HTTPError @@ -37,7 +38,7 @@ class WorkspaceFileInfo(object): - def __init__(self, path, object_type, object_id, language=None, **kwargs): # noqa + def __init__(self, path, object_type, language=None, object_id=None, **kwargs): # noqa self.path = path self.object_type = object_type self.language = language @@ -62,6 +63,14 @@ def to_row(self, is_long_form, is_absolute, with_object_id=False): return result + def to_json(self): + return { + 'path': self.path, + 'object_type': self.object_type, + 'language': self.language, + 'object_id': self.object_id, + } + @property def is_dir(self): return self.object_type == DIRECTORY @@ -98,6 +107,7 @@ def list_objects(self, workspace_path, headers=None): if 'objects' not in response: return [] objects = response['objects'] + return [WorkspaceFileInfo.from_json(f) for f in objects] def mkdirs(self, workspace_path, headers=None): @@ -186,3 +196,34 @@ def export_workspace_dir(self, source_path, target_path, overwrite, headers=None click.echo('{} already exists locally as {}. Skip.'.format(cur_src, cur_dst)) else: click.echo('{} is neither a dir or a notebook. Skip.'.format(cur_src)) + + def list_directory_info(self, workspace_path, headers=None): + # first, we need to trim the workspace_path + # then we need the parent + workspace_path = workspace_path.rstrip('/') + parent_path = dirname(workspace_path) + if len(parent_path) == 0: + parent_path = '/' + + last_entry = workspace_path.split('/')[-1] + + return [object_value for object_value in self.list_objects(parent_path, headers) if + last_entry in object_value.path] + + def get_id_for_directory(self, path): + # type: (str) -> list[str] + """ + Given a path, use the workspaces API to look up the object id. + :param path: path to a directory + :return: object id, [] if not found + """ + + if not path: + return [] + + objects = self.list_directory_info(path) + if not objects: + return [] + + # ls -d already filtered for us + return [workspace_object.object_id for workspace_object in objects] diff --git a/tests/permissions/__init__.py b/tests/permissions/__init__.py new file mode 100644 index 00000000..b0c9feac --- /dev/null +++ b/tests/permissions/__init__.py @@ -0,0 +1,22 @@ +# 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. diff --git a/tests/permissions/test_cli.py b/tests/permissions/test_cli.py new file mode 100644 index 00000000..c68dc203 --- /dev/null +++ b/tests/permissions/test_cli.py @@ -0,0 +1,419 @@ +# 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. + +# pylint: disable=redefined-outer-name + +import re + +import mock +import pytest +from click.testing import CliRunner + +import databricks_cli.permissions.cli as cli +from databricks_cli.permissions.api import PermissionTargets +from databricks_cli.utils import pretty_format +from tests.test_data import TEST_CLUSTER_ID +from tests.utils import provide_conf + + +def strip_margin(text): + # type: (str) -> str + return re.sub('\n[ \t]*\\|', '\n', text) + + +PERMISSIONS_RETURNS = { + 'get': { + 'clusters': { + TEST_CLUSTER_ID: { + 'object_id': '/clusters/{}'.format(TEST_CLUSTER_ID), + 'object_type': 'cluster', + 'access_control_list': [ + { + 'group_name': 'admins', + 'all_permissions': [ + { + 'permission_level': 'CAN_MANAGE', + 'inherited': True, + 'inherited_from_object': [ + '/clusters/' + ] + } + ] + } + ] + } + } + }, + 'list-permissions': { + 'clusters': { + 'permission_levels': [ + { + 'permission_level': 'CAN_MANAGE', + 'description': 'Can Manage permission on cluster' + }, + { + 'permission_level': 'CAN_RESTART', + 'description': 'Can Restart permission on cluster' + }, + { + 'permission_level': 'CAN_ATTACH_TO', + 'description': 'Can Attach To permission on cluster' + } + ] + }, + 'directories': { + 'permission_levels': [ + { + 'permission_level': 'CAN_READ', + 'description': 'Can view and comment on notebooks in the directory' + }, + { + 'permission_level': 'CAN_RUN', + 'description': 'Can view, comment, attach/detach, and run commands in ' + + 'notebooks in the directory' + }, + { + 'permission_level': 'CAN_EDIT', + 'description': 'Can view, comment, attach/detach, run commands, and edit ' + + 'notebooks in the directory' + }, + { + 'permission_level': 'CAN_MANAGE', + 'description': 'Can view, comment, attach/detach, run commands, and edit ' + + 'notebooks in the folder, and can create, delete, and change ' + + 'permissions of items in the directory' + } + ] + }, + 'instance-pools': { + 'permission_levels': [ + { + "permission_level": "CAN_MANAGE", + "description": "Can Manage permission on a pool" + }, + { + "permission_level": "CAN_ATTACH_TO", + "description": "Can Attach To permission on a pool" + } + ] + }, + 'jobs': { + 'permission_levels': [ + { + 'permission_level': 'IS_OWNER', + 'description': 'Is Owner permission on a job' + }, + { + 'permission_level': 'CAN_MANAGE_RUN', + 'description': 'Can Manage Run permission to trigger or cancel job runs' + }, + { + 'permission_level': 'CAN_VIEW', + 'description': 'Can View permission to view job run results' + } + ] + }, + 'notebooks': { + 'permission_levels': [ + { + 'permission_level': 'CAN_READ', + 'description': 'Can view and comment on the notebook' + }, + { + 'permission_level': 'CAN_RUN', + 'description': 'Can view, comment, attach/detach, and run commands in the' + + 'notebook' + }, + { + 'permission_level': 'CAN_EDIT', + 'description': 'Can view, comment, attach/detach, run commands, and edit ' + + 'the notebook' + }, + { + 'permission_level': 'CAN_MANAGE', + 'description': 'Can view, comment, attach/detach, run commands, edit, and ' + + 'change permissions of the notebook' + } + ] + }, + 'registered-models': { + 'permission_levels': [ + { + 'permission_level': 'CAN_READ', + 'description': 'Can view the details of the registered model and its model ' + + 'versions, and use the model versions.' + }, + { + 'permission_level': 'CAN_EDIT', + 'description': 'Can view and edit the details of a registered model and ' + + 'its model versions (except stage changes), and add ' + + 'new model versions.' + }, + { + 'permission_level': 'CAN_MANAGE_STAGING_VERSIONS', + 'description': 'Can view and edit the details of a registered model and its ' + + 'model versions, add new model versions, and manage stage ' + + 'transitions between non-Production stages.' + }, + { + 'permission_level': 'CAN_MANAGE_PRODUCTION_VERSIONS', + 'description': 'Can view and edit the details of a registered model and its ' + + 'model versions, add new model versions, and manage stage ' + + 'transitions between any stages.' + }, + { + 'permission_level': 'CAN_MANAGE', + 'description': 'Can manage permissions on, view all details of, and perform ' + + 'all actions on the registered model and its model versions.' + } + ] + } + }, + 'add': { + 'clusters': { + 'add-manage-group-name': { + 'object_id': TEST_CLUSTER_ID, + 'object_type': 'cluster', + 'access_control_list': [ + { + 'group_name': 'admins', + 'all_permissions': [ + { + 'permission_level': 'CAN_MANAGE', + 'inherited': True, + 'inherited_from_object': [ + '/clusters/' + ] + } + ] + }, + { + 'group_name': 'sam', + 'all_permissions': [ + { + 'permission_level': 'CAN_MANAGE', + 'inherited': False + } + ] + } + ] + }, + 'add-manage-user-name': { + 'object_id': TEST_CLUSTER_ID, + 'object_type': 'cluster', + 'access_control_list': [ + { + 'group_name': 'admins', + 'all_permissions': [ + { + 'permission_level': 'CAN_MANAGE', + 'inherited': True, + 'inherited_from_object': [ + '/clusters/' + ] + } + ] + }, + { + 'user_name': 'sam', + 'all_permissions': [ + { + 'permission_level': 'CAN_MANAGE', + 'inherited': False + } + ] + } + ] + } + } + }, + 'ls': { + '/': { + "object_id": "/directories/1", + "object_type": "directory", + "access_control_list": [ + { + "group_name": "admins", + "all_permissions": [ + { + "permission_level": "CAN_MANAGE", + "inherited": True, + "inherited_from_object": [ + "/directories/" + ] + } + ] + } + ] + } + } +} + + +@pytest.fixture() +def permissions_sdk_mock(): + with mock.patch('databricks_cli.permissions.api.PermissionsService') as SdkMock: + _permissions_sdk_mock = mock.MagicMock() + SdkMock.return_value = _permissions_sdk_mock + # _permissions_sdk_mock.get_cluster = mock.MagicMock(return_value={}) + + yield _permissions_sdk_mock + + +def help_test(cli_function, service_function=None, rv=None, args=None, format_result=True): + """ + This function makes testing the cli functions that just pass data through simpler + """ + + if args is None: + args = [] + + with mock.patch('databricks_cli.permissions.cli.click.echo') as echo_mock: + if service_function: + service_function.return_value = rv + runner = CliRunner() + result = runner.invoke(cli_function, args) + if result.exit_code != 0: + print(result.output) + if format_result: + expected = pretty_format(rv) + else: + expected = rv + assert echo_mock.call_args[0][0] == expected + + +@pytest.fixture() +def perms_api_mock(): + with mock.patch('databricks_cli.permissions.cli.PermissionsApi') as PermissionsApiMock: + _perms_api_mock = mock.MagicMock() + PermissionsApiMock.return_value = _perms_api_mock + yield _perms_api_mock + + +@provide_conf +def test_get_cli(perms_api_mock): + return_value = PERMISSIONS_RETURNS['get']['clusters'] + perms_api_mock.get_permissions.return_value = return_value + help_test(cli.get_cli, args=[ + '--object-type', + 'clusters', + '--object-id', + TEST_CLUSTER_ID + ], rv=return_value) + + assert perms_api_mock.get_permissions.call_args[0][0] == 'clusters' + assert perms_api_mock.get_permissions.call_args[0][1] == TEST_CLUSTER_ID + + +@provide_conf +def test_list_permissions_types_cli(permissions_sdk_mock): + for perm_type in PermissionTargets.values(): + return_value = PERMISSIONS_RETURNS['list-permissions'][perm_type] + permissions_sdk_mock.get_possible_permissions.return_value = return_value + help_test(cli.list_permissions_types_cli, args=[ + '--object-type', + perm_type, + '--object-id', + TEST_CLUSTER_ID + ], rv=return_value) + + +@provide_conf +def test_add_cluster_manage(permissions_sdk_mock): + perm_type = 'clusters' + + for add_type in ['user-name', 'group-name']: + return_value = PERMISSIONS_RETURNS['add'][perm_type]['add-manage-{}'.format(add_type)] + permissions_sdk_mock.add_permissions.return_value = return_value + help_test(cli.add_cli, args=[ + '--object-type', + perm_type, + '--object-id', + TEST_CLUSTER_ID, + '--{}'.format(add_type), + 'sam', + '--permission-level', + 'manage' + ], rv=return_value) + + +@provide_conf +def test_list_permissions_targets_cli(): + help_test(cli.list_permissions_targets_cli, args=None, rv=cli.POSSIBLE_OBJECT_TYPES, + format_result=False) + + +@provide_conf +def test_list_permissions_level_cli(): + help_test(cli.list_permissions_level_cli, args=None, rv=cli.POSSIBLE_PERMISSION_LEVELS, + format_result=False) + + +@pytest.fixture() +def workspace_api_mock(): + with mock.patch('databricks_cli.permissions.cli.WorkspaceApi') as WorkspaceApiMock: + workspace_api_mock = mock.MagicMock() + WorkspaceApiMock.return_value = workspace_api_mock + yield workspace_api_mock + + +@provide_conf +def test_directory_cli_missing_path(permissions_sdk_mock, workspace_api_mock): + workspace_api_mock.get_id_for_directory.return_value = None + help_test(cli.directory_cli, + args=[ + '--path', + '/workspace/missing.txt' + ], + rv='Failed to find id for /workspace/missing.txt', + format_result=False) + + +@provide_conf +def test_directory_cli(permissions_sdk_mock, workspace_api_mock): + workspace_api_mock.get_id_for_directory.return_value = ['1'] + permissions_sdk_mock.get_permissions.return_value = { + 'object_id': '/directories/1', + 'object_type': 'directory', + 'access_control_list': [ + { + 'group_name': 'admins', + 'all_permissions': [ + { + 'permission_level': 'CAN_MANAGE', + 'inherited': True, + 'inherited_from_object': [ + '/directories/' + ] + } + ] + } + ] + } + + help_test(cli.directory_cli, + args=[ + '--path', + '/' + ], + rv=PERMISSIONS_RETURNS['ls']['/'])