diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 85972d48b..9bce2acdb 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -3,7 +3,8 @@ GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem, \ SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \ HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem, \ - SubscriptionItem, Target + SubscriptionItem, Target, PermissionsCollection, Permission, PermissionsRule, PermissionsGrantee + from .server import RequestOptions, CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, Filter, Sort, \ Server, ServerResponseError, MissingRequiredFieldError, NotSignedInError, Pager from ._version import get_versions diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 63a861cbb..63e4bef14 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -17,3 +17,4 @@ from .view_item import ViewItem from .workbook_item import WorkbookItem from .subscription_item import SubscriptionItem +from .permissions_item import Permission, PermissionsCollection, PermissionsRule, PermissionsGrantee diff --git a/tableauserverclient/models/exceptions.py b/tableauserverclient/models/exceptions.py index 28d738e73..86c28ac33 100644 --- a/tableauserverclient/models/exceptions.py +++ b/tableauserverclient/models/exceptions.py @@ -1,2 +1,6 @@ class UnpopulatedPropertyError(Exception): pass + + +class UnknownGranteeTypeError(Exception): + pass diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py new file mode 100644 index 000000000..e67812e3b --- /dev/null +++ b/tableauserverclient/models/permissions_item.py @@ -0,0 +1,147 @@ +import xml.etree.ElementTree as ET +import logging + +from .exceptions import UnknownGranteeTypeError + + +logger = logging.getLogger("tableau.models.permissions_item") + + +class Permission: + class GranteeType: + User = "user" + Group = "group" + + class CapabilityMode: + Allow = "Allow" + Deny = "Deny" + + class DatasourceCapabilityType: + ChangePermissions = "ChangePermissions" + Connect = "Connect" + Delete = "Delete" + ExportXml = "ExportXml" + Read = "Read" + Write = "Write" + + class WorkbookCapabilityType: + AddComment = "AddComment" + ChangeHierarchy = "ChangeHierarchy" + ChangePermissions = "ChangePermissions" + Delete = "Delete" + ExportData = "ExportData" + ExportImage = "ExportImage" + ExportXml = "ExportXml" + Filter = "Filter" + Read = "Read" + ShareView = "ShareView" + ViewComments = "ViewComments" + ViewUnderlyingData = "ViewUnderlyingData" + WebAuthoring = "WebAuthoring" + Write = "Write" + + class ProjectCapabilityType: + ProjectLeader = "ProjectLeader" + Read = "Read" + Write = "Write" + + +class PermissionsGrantee(object): + def __init__(self, grantee_type, grantee_id): + + if grantee_type not in [ + Permission.GranteeType.User, + Permission.GranteeType.Group, + ]: + raise UnknownGranteeTypeError(grantee_type) + + self._grantee_type = grantee_type + self._grantee_id = grantee_id + + @classmethod + def from_xml_element(cls, xml_element): + tag_without_namespace = xml_element.tag.split("}")[-1] + return cls(tag_without_namespace, xml_element.get("id")) + + def to_xml_element(self): + xml_element = ET.Element(self.grantee_type) + xml_element.set("id", self.grantee_id) + return xml_element + + @property + def grantee_type(self): + return self._grantee_type + + @property + def grantee_id(self): + return self._grantee_id + + +class PermissionsRule(object): + def __init__(self, grantee, permissions_map=None): + self._grantee = grantee + self.permissions_map = permissions_map or {} + + @property + def grantee(self): + return self._grantee + + def to_xml_element(self): + xml_element = ET.Element("granteeCapabilities") + xml_element.append(self.grantee.to_xml_element()) + capabilities_element = ET.SubElement(xml_element, "capabilities") + for permission, mode in self.permissions_map.items(): + ET.SubElement( + capabilities_element, "capability", {"name": permission, "mode": mode} + ) + return xml_element + + +class PermissionsCollection(object): + def __init__(self, rules): + self._rules = rules + + @property + def rules(self): + return self._rules + + @classmethod + def from_response(cls, resp, ns=None): + parsed_response = ET.fromstring(resp) + + capabilities = [] + all_xml = parsed_response.findall(".//t:granteeCapabilities", namespaces=ns) + + for grantee_capability_xml in all_xml: + user_grantee_element = grantee_capability_xml.find( + "./t:user", namespaces=ns + ) + group_grantee_element = grantee_capability_xml.find( + "./t:group", namespaces=ns + ) + grantee_element = ( + user_grantee_element + if user_grantee_element is not None + else group_grantee_element + ) + + grantee = PermissionsGrantee.from_xml_element(grantee_element) + + capability_elements = grantee_capability_xml.findall( + ".//t:capabilities/t:capability", namespaces=ns + ) + capability_map = { + el.get("name"): el.get("mode") for el in capability_elements + } + + capability_item = PermissionsRule(grantee, capability_map) + + capabilities.append(capability_item) + + return cls(capabilities) + + def to_xml_element(self): + xml_element = ET.Element("permissions") + for permission_rule in self.rules: + xml_element.append(permission_rule.to_xml_element()) + return xml_element diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 92e0282ae..a390ecb2d 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,5 +1,6 @@ import xml.etree.ElementTree as ET from .property_decorators import property_is_enum, property_not_empty +from .exceptions import UnpopulatedPropertyError class ProjectItem(object): diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 8df036516..ca7c3b68e 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -27,6 +27,7 @@ def __init__(self, project_id, name=None, show_tabs=False): self.tags = set() self.materialized_views_config = {'materialized_views_enabled': None, 'run_materialization_now': None} + self._permissions = None @property def connections(self): @@ -35,6 +36,13 @@ def connections(self): raise UnpopulatedPropertyError(error) return self._connections() + @property + def permissions(self): + if self._permissions is None: + error = "Workbook item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._permissions + @property def content_url(self): return self._content_url @@ -120,6 +128,9 @@ def materialized_views_config(self, value): def _set_connections(self, connections): self._connections = connections + def _set_permissions(self, permissions): + self._permissions = permissions + def _set_views(self, views): self._views = views diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 8c5cb314c..19b9f9d17 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -4,7 +4,8 @@ from .sort import Sort from .. import ConnectionItem, DatasourceItem, JobItem, BackgroundJobItem, \ GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\ - UserItem, ViewItem, WorkbookItem, TaskItem, SubscriptionItem + UserItem, ViewItem, WorkbookItem, TaskItem, SubscriptionItem, PermissionsCollection, \ + Permission, PermissionsRule, PermissionsGrantee from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ Sites, Users, Views, Workbooks, Subscriptions, ServerResponseError, \ MissingRequiredFieldError diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py new file mode 100644 index 000000000..4de18c267 --- /dev/null +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -0,0 +1,76 @@ +import logging + +from .. import RequestFactory, PermissionsCollection + +from .endpoint import Endpoint, api +from .exceptions import MissingRequiredFieldError + + +logger = logging.getLogger(__name__) + + +class _PermissionsEndpoint(Endpoint): + """ Adds permission model to another endpoint + + Tableau permissions model is identical between objects but they are nested under + the parent object endpoint (i.e. permissions for workbooks are under + /workbooks/:id/permission). This class is meant to be instantiated inside a + parent endpoint which has these supported endpoints + """ + + def __init__(self, parent_srv, owner_baseurl): + super(_PermissionsEndpoint, self).__init__(parent_srv) + + # owner_baseurl is the baseurl of the parent. The MUST be a lambda + # since we don't know the full site URL until we sign in. If + # populated without, we will get a sign-in error + self.owner_baseurl = owner_baseurl + + def update(self, item, permission_collection): + url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id) + update_req = RequestFactory.Permission.add_req(permission_collection) + response = self.put_request(url, update_req) + permissions = PermissionsCollection.from_response( + response.content, self.parent_srv.namespace + ) + + logger.info("Updated permissions for item {0}".format(item.id)) + + return permissions + + def delete(self, item, permission_collection): + for rule in permission_collection.rules: + for capability_type, capability_mode in rule.permissions_map.items(): + url = "{0}/{1}/permissions/{2}s/{3}/{4}/{5}".format( + self.owner_baseurl(), + item.id, + rule.grantee.grantee_type, + rule.grantee.grantee_id, + capability_type, + capability_mode, + ) + self.delete_request(url) + logger.info( + "Deleted permission for {0} {1} item {2}".format( + rule.grantee.grantee_type, rule.grantee.grantee_id, item.id + ) + ) + + def populate(self, item): + if not item.id: + error = ( + "Server item is missing ID. Item must be retrieved from server first." + ) + raise MissingRequiredFieldError(error) + + permissions = self._get_permissions(item) + item._set_permissions(permissions) + logger.info("Populated permissions for item (ID: {0})".format(item.id)) + + def _get_permissions(self, item, req_options=None): + url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id) + server_response = self.get_request(url, req_options) + permissions = PermissionsCollection.from_response( + server_response.content, self.parent_srv.namespace + ) + return permissions diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 772ed79b9..38ef8c3cf 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -1,4 +1,5 @@ -from .endpoint import Endpoint, api, parameter_added_in +from .endpoint import api, parameter_added_in, Endpoint +from .permissions_endpoint import _PermissionsEndpoint from .exceptions import InternalServerError, MissingRequiredFieldError from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger @@ -25,6 +26,7 @@ class Workbooks(Endpoint): def __init__(self, parent_srv): super(Workbooks, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) + self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self): @@ -216,6 +218,19 @@ def _get_wb_preview_image(self, workbook_item): preview_image = server_response.content return preview_image + @api(version='2.0') + def populate_permissions(self, item): + self._permissions.populate(item) + + @api(version='2.0') + def update_permission(self, item, permission_item): + permissions = self._permissions.update(item, permission_item) + item._set_permissions(permissions) + + @api(version='2.0') + def delete_permission(self, item, capability_item): + self._permissions.delete(item, capability_item) + # Publishes workbook. Chunking method if file over 64MB @api(version="2.0") @parameter_added_in(as_job='3.0') diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index fdc799af1..72dbb3ef5 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -142,30 +142,9 @@ def update_req(self, group_item, default_site_role): class PermissionRequest(object): - def _add_capability(self, parent_element, capability_set, mode): - for capability_item in capability_set: - capability_element = ET.SubElement(parent_element, 'capability') - capability_element.attrib['name'] = capability_item - capability_element.attrib['mode'] = mode - - def add_req(self, permission_item): + def add_req(self, permission_collection): xml_request = ET.Element('tsRequest') - permissions_element = ET.SubElement(xml_request, 'permissions') - - for user_capability in permission_item.user_capabilities: - grantee_element = ET.SubElement(permissions_element, 'granteeCapabilities') - grantee_capabilities_element = ET.SubElement(grantee_element, user_capability.User) - grantee_capabilities_element.attrib['id'] = user_capability.grantee_id - capabilities_element = ET.SubElement(grantee_element, 'capabilities') - self._add_capability(capabilities_element, user_capability.allowed, user_capability.Allow) - self._add_capability(capabilities_element, user_capability.denied, user_capability.Deny) - - for group_capability in permission_item.group_capabilities: - grantee_element = ET.SubElement(permissions_element, 'granteeCapabilities') - ET.SubElement(grantee_element, group_capability, id=group_capability.grantee_id) - capabilities_element = ET.SubElement(grantee_element, 'capabilities') - self._add_capability(capabilities_element, group_capability.allowed, group_capability.Allow) - self._add_capability(capabilities_element, group_capability.denied, group_capability.Deny) + xml_request.append(permission_collection.to_xml_element()) return ET.tostring(xml_request) diff --git a/test/assets/workbook_populate_permissions.xml b/test/assets/workbook_populate_permissions.xml new file mode 100644 index 000000000..57517d719 --- /dev/null +++ b/test/assets/workbook_populate_permissions.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/workbook_update_permissions.xml b/test/assets/workbook_update_permissions.xml new file mode 100644 index 000000000..d220bb660 --- /dev/null +++ b/test/assets/workbook_update_permissions.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/test_workbook.py b/test/test_workbook.py index ae814c0b2..65f2e687a 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -16,6 +16,7 @@ GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_empty.xml') GET_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get.xml') POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_connections.xml') +POPULATE_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_permissions.xml') POPULATE_PDF = os.path.join(TEST_ASSET_DIR, 'populate_pdf.pdf') POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, 'RESTAPISample Image.png') POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views.xml') @@ -23,6 +24,7 @@ PUBLISH_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish.xml') PUBLISH_ASYNC_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish_async.xml') UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_update.xml') +UPDATE_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_update_permissions.xml') class WorkbookTests(unittest.TestCase): @@ -270,6 +272,161 @@ def test_populate_connections(self): self.assertEqual('4506225a-0d32-4ab1-82d3-c24e85f7afba', single_workbook.connections[0].datasource_id) self.assertEqual('World Indicators', single_workbook.connections[0].datasource_name) + def test_populate_permissions(self): + with open(POPULATE_PERMISSIONS_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl + '/21778de4-b7b9-44bc-a599-1506a2639ace/permissions', text=response_xml) + single_workbook = TSC.WorkbookItem('test') + single_workbook._id = '21778de4-b7b9-44bc-a599-1506a2639ace' + + self.server.workbooks.populate_permissions(single_workbook) + permissions = single_workbook.permissions + + self.assertEqual(permissions.rules[0].grantee.grantee_type, TSC.Permission.GranteeType.Group) + self.assertEqual(permissions.rules[0].grantee.grantee_id, '5e5e1978-71fa-11e4-87dd-7382f5c437af') + self.assertDictEqual(permissions.rules[0].permissions_map, { + TSC.Permission.WorkbookCapabilityType.WebAuthoring: TSC.Permission.CapabilityMode.Allow, + TSC.Permission.WorkbookCapabilityType.Read: TSC.Permission.CapabilityMode.Allow, + TSC.Permission.WorkbookCapabilityType.Filter: TSC.Permission.CapabilityMode.Allow, + TSC.Permission.WorkbookCapabilityType.AddComment: TSC.Permission.CapabilityMode.Allow + }) + + self.assertEqual(permissions.rules[1].grantee.grantee_type, TSC.Permission.GranteeType.User) + self.assertEqual(permissions.rules[1].grantee.grantee_id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a') + self.assertDictEqual(permissions.rules[1].permissions_map, { + TSC.Permission.WorkbookCapabilityType.ExportImage: TSC.Permission.CapabilityMode.Allow, + TSC.Permission.WorkbookCapabilityType.ShareView: TSC.Permission.CapabilityMode.Allow, + TSC.Permission.WorkbookCapabilityType.ExportData: TSC.Permission.CapabilityMode.Deny, + TSC.Permission.WorkbookCapabilityType.ViewComments: TSC.Permission.CapabilityMode.Deny + }) + + def test_update_permissions(self): + with open(UPDATE_PERMISSIONS_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + + with requests_mock.mock() as m: + adapter = m.put(self.baseurl + '/21778de4-b7b9-44bc-a599-1506a2639ace/permissions', text=response_xml) + single_workbook = TSC.WorkbookItem('test') + single_workbook._id = '21778de4-b7b9-44bc-a599-1506a2639ace' + + permisssion_collection = TSC.PermissionsCollection([ + TSC.PermissionsRule( + TSC.PermissionsGrantee(TSC.Permission.GranteeType.Group, '5e5e1978-71fa-11e4-87dd-7382f5c437af'), + { + TSC.Permission.WorkbookCapabilityType.WebAuthoring: TSC.Permission.CapabilityMode.Allow, + TSC.Permission.WorkbookCapabilityType.Read: TSC.Permission.CapabilityMode.Allow, + TSC.Permission.WorkbookCapabilityType.Filter: TSC.Permission.CapabilityMode.Allow, + TSC.Permission.WorkbookCapabilityType.AddComment: TSC.Permission.CapabilityMode.Allow, + } + ), + TSC.PermissionsRule( + TSC.PermissionsGrantee(TSC.Permission.GranteeType.User, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a'), + { + TSC.Permission.WorkbookCapabilityType.ExportImage: TSC.Permission.CapabilityMode.Allow, + TSC.Permission.WorkbookCapabilityType.ShareView: TSC.Permission.CapabilityMode.Allow, + TSC.Permission.WorkbookCapabilityType.ExportData: TSC.Permission.CapabilityMode.Deny, + TSC.Permission.WorkbookCapabilityType.ViewComments: TSC.Permission.CapabilityMode.Deny, + } + ), + ]) + + self.server.workbooks.update_permission(single_workbook, permisssion_collection) + + # Check request + request_el = ET.fromstring(adapter.last_request.text) + permissions_el = request_el.find('./permissions') + grantee_capabilities = permissions_el.findall('./granteeCapabilities') + + permission_map = [] + + for grantee_capability in grantee_capabilities: + user = grantee_capability.find('./user') + group = grantee_capability.find('./group') + grantee = user if user is not None else group + permission_map.append({ + 'grantee_id': grantee.get('id'), + 'grantee_type': grantee.tag, + 'capabilities': {}, + }) + for capability in grantee_capability.find('./capabilities'): + permission_map[-1]['capabilities'][capability.get('name')] = capability.get('mode') + + self.assertEqual( + permission_map, + [ + { + 'grantee_id': '5e5e1978-71fa-11e4-87dd-7382f5c437af', + 'grantee_type': 'group', + 'capabilities': { + 'WebAuthoring': 'Allow', + 'Read': 'Allow', + 'Filter': 'Allow', + 'AddComment': 'Allow' + } + }, + { + 'grantee_id': '7c37ee24-c4b1-42b6-a154-eaeab7ee330a', + 'grantee_type': 'user', + 'capabilities': { + 'ExportImage': 'Allow', + 'ShareView': 'Allow', + 'ExportData': 'Deny', + 'ViewComments': 'Deny' + } + } + ] + + ) + + def test_delete_permissions(self): + with requests_mock.mock() as m: + adapter = m.delete(requests_mock.ANY) + single_workbook = TSC.WorkbookItem('test') + single_workbook._id = '21778de4-b7b9-44bc-a599-1506a2639ace' + + permisssion_collection = TSC.PermissionsCollection([ + TSC.PermissionsRule( + TSC.PermissionsGrantee(TSC.Permission.GranteeType.Group, '5e5e1978-71fa-11e4-87dd-7382f5c437af'), + { + TSC.Permission.WorkbookCapabilityType.WebAuthoring: TSC.Permission.CapabilityMode.Allow, + TSC.Permission.WorkbookCapabilityType.Read: TSC.Permission.CapabilityMode.Allow, + TSC.Permission.WorkbookCapabilityType.Filter: TSC.Permission.CapabilityMode.Allow, + TSC.Permission.WorkbookCapabilityType.AddComment: TSC.Permission.CapabilityMode.Allow, + } + ), + TSC.PermissionsRule( + TSC.PermissionsGrantee(TSC.Permission.GranteeType.User, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a'), + { + TSC.Permission.WorkbookCapabilityType.ExportImage: TSC.Permission.CapabilityMode.Allow, + TSC.Permission.WorkbookCapabilityType.ShareView: TSC.Permission.CapabilityMode.Allow, + TSC.Permission.WorkbookCapabilityType.ExportData: TSC.Permission.CapabilityMode.Deny, + TSC.Permission.WorkbookCapabilityType.ViewComments: TSC.Permission.CapabilityMode.Deny, + } + ), + ]) + + self.server.workbooks.delete_permission(single_workbook, permisssion_collection) + + # Check request + url_requests_made = [r.url for r in adapter.request_history] + + base_url = '{}/{}'.format(self.server.workbooks.baseurl, single_workbook._id) + group_base_url = '{}/permissions/groups/{}'.format(base_url, '5e5e1978-71fa-11e4-87dd-7382f5c437af') + user_base_url = '{}/permissions/users/{}'.format(base_url, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a') + + for url in [ + '{}/WebAuthoring/Allow'.format(group_base_url), + '{}/Read/Allow'.format(group_base_url), + '{}/Filter/Allow'.format(group_base_url), + '{}/AddComment/Allow'.format(group_base_url), + '{}/ExportImage/Allow'.format(user_base_url), + '{}/ShareView/Allow'.format(user_base_url), + '{}/ExportData/Deny'.format(user_base_url), + '{}/ViewComments/Deny'.format(user_base_url), + ]: + self.assertIn(url, url_requests_made) + def test_populate_connections_missing_id(self): single_workbook = TSC.WorkbookItem('test') self.assertRaises(TSC.MissingRequiredFieldError,