diff --git a/__init__.py b/__init__.py index 62d9cd9..afdd0af 100644 --- a/__init__.py +++ b/__init__.py @@ -8,3 +8,4 @@ #from .tableau._server_rest import TableauServerRest, TableauServerRest33 from .tableau_rest_api_connection import * from .tableau_server_rest import * +from .rest_tokens_manager import * diff --git a/examples/limited_rest_api_wrapping_tableau_rest_api.py b/examples/limited_rest_api_wrapping_tableau_rest_api.py new file mode 100644 index 0000000..bbd13ba --- /dev/null +++ b/examples/limited_rest_api_wrapping_tableau_rest_api.py @@ -0,0 +1,175 @@ +from tableau_tools import * +#import time + +# This script shows two example generic functions which utilize the RestTokensManager class +# It is an example of how you can create a wrapper REST API which exposes some of the +# Tableau REST API functionality but not all of it, while using the impersonation feature +# so that each request is performed for the User, without needing their credentials (only admin credentials) +# Taken from a Django project, but Flask code would work similarly + +# You would probably store your admin credentials securely in an settings or ENV file +server = settings.TABLEAU_SERVER +admin_username = settings.TABLEAU_ADMIN_USERNAME +admin_password = settings.TABLEAU_ADMIN_PASSWORD +# This is most likely just 'default' but you might have some reason not to bootstrap from there +default_site_content_url = settings.TABLEAU_DEFAULT_SITE_CONTENT_URL + +tableau_tools_logger = Logger('tableau_tools.log') + +# Connect to the default site to bootstrap the process + +# In a running app server, the same process is always running so it needs a Master login PER site +# But also, REST API sessions do timeout, so we need to check for that possibility and remove +# Sessions once they have timed out + +# You must use an Admin Username and Password, as PAT does not have Impersonation at this time (2020.2) +d = TableauServerRest32(server=server, username=admin_username, password=admin_password, + site_content_url=default_site_content_url) +# alternatively could use the older TableauRestApiConnection objects if you had code built on those objects + +# If you are using a self-signed cert or need to pass in a CERT chain, this pass directly to +# the requests library https://requests.readthedocs.io/en/master/user/quickstart/ to do whatever SSL option you need: +# d.verify_ssl_cert = False + +d.enable_logging(tableau_tools_logger) +# Other options you might turn off for deeper logging: +# tableau_tools_logger.enable_request_logging() +# tableau_tools_logger.enable_response_logging() +# tableau_tools_logger.enable_debug_level() + + +# This manages all the connection tokens here on out +connections = RestTokensManager() + +# +# RestTokensManager methods are all functional -- you pass in a TableauServerRest or TableauRestApiConnection object +# and then it perhaps actions on that object, such as logging in as a different user or switching to +# an already logged in user. +# Internally it maintains a data structure with the Admin tokens for any site that has been signed into +# And the individual User Tokens for any User / Site combination that has been signed into +# It does not RUN any REST API commands other than sign-in: You run those commands on the +# connection object once it has been returned + +# For example, once this is run, the connection object 'd' will have been signed in, and you can +# do any REST API command against 'd', and it will be done as the master on the default site +# This is just bootstrapping at the very beginning to make sure we've connected successfully +# with the admin credentials. If there are errors at this point, something is likely wrong +# with the configuration/credentials or the Tableau Server +default_token = connections.sign_in_connection_object(d) + +# Next is a generic_request function (based on Django pattern), that utilizes the connections object + +# Every one of our REST methods follows basically this pattern +# So it has been made generic +# You pass the callback function to do whatever you want with +# the REST API object and whatever keyword arguments it needs +# Callback returns a valid type of HttpResponse object and we're all good +def generic_request(request, site, callback_function, **kwargs): + # Generic response to start. This will be returned if no other condition overwrites it + response = HttpResponseServerError() + + # If request is none, then it is an admin level function + if request is not None: + # Check user, if non, response is Http Forbidden + # This function represents whatever your application needs to do to tell you the user who has logged in securely + username = check_user_session(request) + if username is None: + response = HttpResponseForbidden() + return response + else: + # If username is none, the request is run as the Site Admin + username = None + + # Create Connection Object for Given User + # Just create, but don't sign in. Will use swap via the TokenManager + t = TableauServerRest32(server=server, username=admin_username, password=admin_password, + site_content_url=default_site_content_url) + + # Again, you might need to pass in certain arguments to requests library if using a self-signed cert + #t.verify_ssl_cert = False + t.enable_logging(tableau_tools_logger) + + # Check for connection, attempt to reestablish if possible + if connections.connection_signed_in is False: + tableau_tools_logger.log("Signing back in to the master user") + # If the reconnection fails, return Server Error response + if connections.sign_in_connection_object(rest_connection=t) is False: + # This is a Django error response, take it as whatever HTTP error you'd like to throw + response = HttpResponseServerError() + # If connection is already confirmed, just swap to the user token for the site + else: + # Site Admin level request + if username is None: + tableau_tools_logger.log("Swapping to Site Admin ") + connections.switch_to_site_master(rest_connection=t, site_content_url=site) + tableau_tools_logger.log("Token is now {}".format(t.token)) + # Request as a particular username + else: + tableau_tools_logger.log("Swapping in existing user token for user {}".format(username)) + connections.switch_user_and_site(rest_connection=t, username=username, site_content_url=site) + tableau_tools_logger.log("Token is now {}".format(t.token)) + + # Do action with connection + # Whatever callback function was specified will be called with RestApiConnection / TableauServerRest object as first argument + # then any other kwargs in the order they were passed. + # The callback function must return a Django HttpResponse (or related) object + # But within the callback, 't' is the TableauServerRest or TableauRestApiConnection object with the token for the + # particular user you want + try: + response = callback_function(t, **kwargs) + + except NotSignedInException as e: + if username is None: + tableau_tools_logger.log("Master REST API session on site {} has timed out".format(site)) + del connections.site_master_tokens[site] + # Rerun the connection + tableau_tools_logger.log("Creating new user token for site master") + connections.switch_to_site_master(rest_connection=t, site_content_url=site) + tableau_tools_logger.log("Token is now {}".format(t.token)) + else: + tableau_tools_logger.log("User {} REST API session on vertical {} has timed out".format(username, site)) + del connections.site_user_tokens[site][username] + # Rerun the connection + tableau_tools_logger.log("Creating new user token for username {} on vertical {}".format(username, site)) + connections.switch_user_and_site(rest_connection=t, username=username, site_content_url=site) + tableau_tools_logger.log("Token is now {}".format(t.token)) + # Rerun the orginal callback command + tableau_tools_logger.log("Doing callback function again now that new token exists") + response = callback_function(t, **kwargs) + # Originally, the code looked at the following two exceptions. This is been replaced by looking at NotSignedInException + # RecoverableHTTPException is an exception from tableau_tools, when it is known what the error represents + # HTTPError is a Requests library exception, which might happen if tableau_tools doesn't wrap the particular error. + # except (RecoverableHTTPException, HTTPError) as e: + # if e.http_code == 401: + except Exception as e: + raise e + # Destroy REST API Connection Object, which is just used within this code block + del t + # Return Response + return response + +# There were originally separate functions but they shared enough code to be merged together +def admin_request(request, site, callback_function, **kwargs): + # We don't pass the 'request' here, because it would have the end user's username attached via the session + # The point is that username ends up None in the generic_request call, forcing it to use the admin + return generic_request(None, site, callback_function, **kwargs) + + +# +# Here is an example of an actual exposed endpoint +# + +# This is what is passed in as the callback function - so rest_connection is the 't' object passed in by generic_request +# Returns all of the Projects a user can see content in, alphabetically sorted +def query_projects(rest_connection: TableauServerRest32): + p_sort = Sort('name', 'asc') + p = rest_connection.query_projects_json(sorts=[p_sort, ]) + return JsonResponse(p) + +# An exposed endpoint linked to an actual URL +def projects(request, site): + #log("Starting to request all workbooks") + # Note we are just wrapping the generic request (this one doesn't take keyword arguments, but anything after + # 'query_projects' would be passed as an argument into the query_projects function (if it took arguments) + response = generic_request(request, site, query_projects) + return response \ No newline at end of file diff --git a/examples/permissions_auditing.py b/examples/permissions_auditing.py index b9dce8d..69c8c3e 100644 --- a/examples/permissions_auditing.py +++ b/examples/permissions_auditing.py @@ -11,7 +11,7 @@ default.enable_logging(logger) default.signin() -with open('permissions_audit.txt', 'wb') as output_file: +with open('permissions_audit.txt', 'w', newline='') as output_file: # Get all sites content urls for logging in site_content_urls = default.query_all_site_content_urls() @@ -23,15 +23,15 @@ headers = ['Site Content URL', 'Project Name', 'Project LUID', 'Are Permissions Locked?', 'Principal Type', 'Principal Name', 'Principal LUID'] - project_caps = default.available_capabilities[default.api_version]['project'] + project_caps = Permissions.available_capabilities[default.api_version]['project'] for cap in project_caps: - headers.append(',{}'.format(cap)) - workbook_caps = default.available_capabilities[default.api_version]['workbook'] + headers.append(cap) + workbook_caps = Permissions.available_capabilities[default.api_version]['workbook'] for cap in workbook_caps: - headers.append(',{}'.format(cap)) - datasource_caps = default.available_capabilities[default.api_version]['datasource'] + headers.append(cap) + datasource_caps = Permissions.available_capabilities[default.api_version]['datasource'] for cap in datasource_caps: - headers.append(',{}'.format(cap)) + headers.append(cap) output_writer.writerow(headers) for site_content_url in site_content_urls: @@ -53,13 +53,13 @@ all_perms_list = proj_obj.convert_all_permissions_to_list(all_perms[luid]) if site_content_url is None: site_content_url = '' - output_row.append(site_content_url.encode('utf-8')) - output_row.append(project.encode('utf-8')) - output_row.append(projects_dict[project].encode('utf-8')) + output_row.append(site_content_url) + output_row.append(project) + output_row.append(projects_dict[project]) output_row.append(str(proj_obj.are_permissions_locked())) - output_row.append(all_perms[luid]["type"].encode('utf-8')) - output_row.append(all_perms[luid]["name"].encode('utf-8')) - output_row.append(luid.encode('utf-8')) + output_row.append(all_perms[luid]["type"]) + output_row.append(all_perms[luid]["name"]) + output_row.append(luid) output_row.extend(all_perms_list) output_writer.writerow(output_row) diff --git a/examples/template_publish_sample.py b/examples/template_publish_sample.py index cb34e9d..ba56f36 100644 --- a/examples/template_publish_sample.py +++ b/examples/template_publish_sample.py @@ -245,7 +245,7 @@ def __init__(self, orig_content_url): temp_wb_file = t_file.save_new_file('Modified Workbook'.format(wb)) new_workbook_luid = d.workbooks.publish_workbook(workbook_filename=temp_wb_file, workbook_name=wb, project_obj=dest_project, - overwrite=True, check_published_ds=False) + overwrite=True) print('Published new workbook {}'.format(new_workbook_luid)) os.remove(temp_wb_file) diff --git a/rest_tokens_manager.py b/rest_tokens_manager.py new file mode 100644 index 0000000..6b685ee --- /dev/null +++ b/rest_tokens_manager.py @@ -0,0 +1,214 @@ +from .tableau_exceptions import NotSignedInException, RecoverableHTTPException +from .logging_methods import LoggingMethods +from typing import Dict, List +# +# RestConnectionsManager class handles all of the session tokens, so that a single connection object +# can send the requests for any user who is active +# Eventually the token data structure could be put in the cache and shared, so that +# multiple running processes could exist handling lots of traffic but sharing the session tokens +# +class RestTokensManager(LoggingMethods): + # The tokens manager is separate from the connection object + # It is not static, but all of its methods act UPON a REST Connection object, so you have to pass + # that Rest Connection object in for every method + + # Then you define what you set in the constructor (when you create) here in the __init__ + # Must always start with self (the equivalent of "this" in Python) + def __init__(self): + + # Each site should have a "master token" for signing in as the impersonated user the first time + self.site_master_tokens = {} # site : token + # This collection then holds any tokens from an individual user's session on a given site + self.site_user_tokens = {} # site : { username : token } + + self.default_connection_token = None + self.connection_signed_in = False + + self.sites_luids_dict = None + + + def _sign_in_error(self): + self.log("Tableau Server REST API Service unreachable") + # Send an e-mail to settings.SYS_ADMIN_EMAIL_ALIAS if in Develop or Production Environment + # IMPLEMENT + # If basic rest_connection_object is None, then always return a "Service Not Available" response + self.connection_signed_in = False + return False + + # This method can sign in from the very beginning if necessary + def _sign_in(self, rest_connection): + rest_connection.signin() + self.connection_signed_in = True + self.default_connection_token = rest_connection + + # Query all the sites to allow for skipping a sign-in once you have the token + sites = rest_connection.query_sites() + self.sites_luids_dict = {} + for site in sites: + self.sites_luids_dict[site.get('contentUrl')] = site.get('id') + return True + + # Signs in to the default site with the Server Admin user credentials + def sign_in_connection_object(self, rest_connection): + # This is a failure within this code, not a failure to reach the Tableau Server and sign in + if rest_connection is None: + raise NotSignedInException() + + # Try to sign in to the Tableau Server REST API + try: + return self._sign_in(rest_connection) + + # Trying all these exception types than capturing anything, but probably should figure exactly what is wrong + except NotSignedInException as e: + try: + return self._sign_in(rest_connection) + except: + return self._sign_in_error() + # Try to sign-in again? + except RecoverableHTTPException as e: + try: + return self._sign_in(rest_connection) + except: + return self._sign_in_error() + # Should be capturing requests ConnectionError exception + except Exception as e: + try: + return self._sign_in(rest_connection) + except: + return self._sign_in_error() + + # Signs in to a particular site with the Server Admin + def sign_in_site_master(self, rest_connection, site_content_url): + rest_connection.token = None + rest_connection.site_content_url = site_content_url + try: + rest_connection.signin() + except: + try: + rest_connection.signin() + except: + raise + + # Now grab that token + self.site_master_tokens[site_content_url] = {"token": rest_connection.token, + "user_luid": rest_connection.user_luid} + + # If no exist site + if site_content_url not in self.site_user_tokens: + self.site_user_tokens[site_content_url] = {} + + # And reset back to the default + rest_connection.token = self.default_connection_token + return True + + # All this check is if a user token exists + def check_user_token(self, rest_connection, username, site_content_url): + self.log('Checking user tokens. Current site master tokens are: {} . Current user tokens are: {}'.format(self.site_master_tokens, self.site_user_tokens)) + + # Has the site been not used before? If not, create it + if site_content_url not in self.site_master_tokens.keys(): + # If the site has no master token, create it + # But we're keeping the same connection object, to limit the total number of tokens + self.sign_in_site_master(rest_connection, site_content_url) + + # Also create an entry in the users dict for this site. The check is probably unnecessary but why not + if site_content_url not in self.site_user_tokens.keys(): + self.site_user_tokens[site_content_url] = {} + # No user token can exist if nothing even existed on that site yet + return False + # Do they have an entry? + elif username in self.site_user_tokens[site_content_url].keys(): + # Now check if a token exists + if self.site_user_tokens[site_content_url][username] is None: + return False + else: + return True + # Didn't find them, no connection + else: + return False + + + def create_user_connection(self, rest_connection, username, site_content_url): + # Swap to the master session for the site to get the user luid + + self.log("Swapping to master site token {}".format(self.site_master_tokens[site_content_url]["token"])) + master_token = self.site_master_tokens[site_content_url]["token"] + master_user_luid = self.site_master_tokens[site_content_url]["user_luid"] + self.log("sites_luid_dict: {}".format(self.sites_luids_dict)) + master_site_luid = self.sites_luids_dict[site_content_url] + self.log("Master Site LUID for swap is {} on site {}".format(master_site_luid, site_content_url)) + rest_connection.swap_token(site_luid=master_site_luid, user_luid=master_user_luid, token=master_token) + # Needed for Signin commands + rest_connection.site_content_url = site_content_url + try: + self.log("Trying to get user_luid for {}".format(username)) + user_luid = rest_connection.query_user_luid(username) + + except: + # Retry at least once + try: + user_luid = rest_connection.query_user_luid(username) + except: + # Assume something wrong with the site_master token, create new session + try: + self.sign_in_site_master(rest_connection, site_content_url) + rest_connection.token = self.site_master_tokens[site_content_url]["token"] + user_luid = rest_connection.query_user_luid(username) + except: + # Maybe another check here? + raise + + + # Now blank the connection token so you can sign in + rest_connection.token = None + try: + rest_connection.signin(user_luid) + # Storing a dict here with the user_luid because it will be useful with swapping + self.site_user_tokens[site_content_url][username] = {"token": rest_connection.token, "user_luid": user_luid} + # Should this be exception instead of return of True? + return True + # This is bad practice to capture any exception, improve when we know what a "user not found" looks like + + except: + # Try one more time in case it is connection issue + try: + rest_connection.signin(user_luid) + self.site_user_tokens[site_content_url][username] = {"token": rest_connection.token, "user_luid": user_luid} + except: + # Should this be exception instead of return of True? + return False + + def switch_user_and_site(self, rest_connection, username: str, site_content_url: str) -> Dict: + user_exists = self.check_user_token(rest_connection, username, site_content_url) + if user_exists is False: + # Create the connection + self.log("Had to create new user connection for {} on site {}".format(username, site_content_url)) + self.create_user_connection(rest_connection, username, site_content_url) + self.log("Created connection. User {} on site {} has token {}".format(username, site_content_url, rest_connection.token)) + elif user_exists is True: + # This token could be out of date, but test for that exception when you try to run a command + + self.log("Existing user connection found for {} on {}".format(username, site_content_url)) + token = self.site_user_tokens[site_content_url][username]["token"] + user_luid = self.site_user_tokens[site_content_url][username]["user_luid"] + site_luid = self.sites_luids_dict[site_content_url] + self.log("Swapping connection to existing token {}".format(token)) + rest_connection.swap_token(site_luid=site_luid, user_luid=user_luid, token=token) + self.log("RestAPi object token {} is now in place for user {} and site {}".format(rest_connection.token, + rest_connection.user_luid, + rest_connection.site_luid)) + else: + raise Exception() + # Return this here so it can be cached + return self.site_user_tokens + + def switch_to_site_master(self, rest_connection, site_content_url): + if site_content_url not in self.site_master_tokens.keys(): + self.sign_in_site_master(rest_connection, site_content_url) + + token = self.site_master_tokens[site_content_url]["token"] + user_luid = self.site_master_tokens[site_content_url]["user_luid"] + site_luid = self.sites_luids_dict[site_content_url] + rest_connection.swap_token(site_luid=site_luid, user_luid=user_luid, token=token) + # Return this here so it can be cached + return self.site_master_tokens \ No newline at end of file diff --git a/setup.py b/setup.py index 72c0965..308483b 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name='tableau_tools', python_requires='>=3.6', - version='5.1.3', + version='5.2.0', packages=['tableau_tools', 'tableau_tools.tableau_rest_api', 'tableau_tools.tableau_documents', 'tableau_tools.examples', 'tableau_tools.tableau_rest_api.methods'], url='https://github.com/bryantbhowell/tableau_tools', diff --git a/tableau_rest_api/methods/project.py b/tableau_rest_api/methods/project.py index 79aeabe..aef0db4 100644 --- a/tableau_rest_api/methods/project.py +++ b/tableau_rest_api/methods/project.py @@ -64,8 +64,9 @@ def create_project(self, project_name: Optional[str] = None, project_desc: Optio new_project = self.send_add_request(url, tsr) self.end_log_block() project_luid = new_project.findall('.//t:project', self.ns_map)[0].get("id") + proj_xml = new_project.findall('.//t:project', self.ns_map)[0] if no_return is False: - return self.get_published_project_object(project_luid, new_project) + return self.get_published_project_object(project_luid, proj_xml) except RecoverableHTTPException as e: if e.http_code == 409: self.log('Project named {} already exists, finding and returning the Published Project Object'.format(