From 0ebcde4c8e370d45d118595252487969c5de9202 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 25 Apr 2025 22:26:20 -0500 Subject: [PATCH 1/7] feat: support idp configurations --- tableauserverclient/__init__.py | 2 ++ tableauserverclient/models/__init__.py | 3 +- tableauserverclient/models/site_item.py | 28 +++++++++++++++++++ tableauserverclient/models/user_item.py | 25 ++++++++++++++++- .../server/endpoint/sites_endpoint.py | 19 ++++++++++++- tableauserverclient/server/request_factory.py | 5 ++++ 6 files changed, 79 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 957a820db..538f85221 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -35,6 +35,7 @@ Resource, RevisionItem, ScheduleItem, + SiteAuthConfiguration, SiteItem, ServerInfoItem, SubscriptionItem, @@ -121,6 +122,7 @@ "ServerInfoItem", "ServerResponseError", "SiteItem", + "SiteAuthConfiguration", "Sort", "SubscriptionItem", "TableauAuth", diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index e4131b720..10c3149f1 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -35,7 +35,7 @@ from tableauserverclient.models.revision_item import RevisionItem from tableauserverclient.models.schedule_item import ScheduleItem from tableauserverclient.models.server_info_item import ServerInfoItem -from tableauserverclient.models.site_item import SiteItem +from tableauserverclient.models.site_item import SiteItem, SiteAuthConfiguration from tableauserverclient.models.subscription_item import SubscriptionItem from tableauserverclient.models.table_item import TableItem from tableauserverclient.models.tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth, JWTAuth @@ -83,6 +83,7 @@ "RevisionItem", "ScheduleItem", "ServerInfoItem", + "SiteAuthConfiguration", "SiteItem", "SubscriptionItem", "TableItem", diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index e4e146f9c..ab65b97b5 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1188,6 +1188,34 @@ def _parse_element(site_xml, ns): ) +class SiteAuthConfiguration: + """ + Authentication configuration for a site. + """ + + def __init__(self): + self.auth_setting: Optional[str] = None + self.enabled: Optional[bool] = None + self.idp_configuration_id: Optional[str] = None + self.idp_configuration_name: Optional[str] = None + self.known_provider_alias: Optional[str] = None + + @classmethod + def from_response(cls, resp: bytes, ns: dict) -> list["SiteAuthConfiguration"]: + all_auth_configs = list() + parsed_response = fromstring(resp) + all_auth_xml = parsed_response.findall(".//t:siteAuthConfiguration", namespaces=ns) + for auth_xml in all_auth_xml: + auth_config = cls() + auth_config.auth_setting = auth_xml.get("authSetting", None) + auth_config.enabled = string_to_bool(auth_xml.get("enabled", "")) + auth_config.idp_configuration_id = auth_xml.get("idpConfigurationId", None) + auth_config.idp_configuration_name = auth_xml.get("idpConfigurationName", None) + auth_config.known_provider_alias = auth_xml.get("knownProviderAlias", None) + all_auth_configs.append(auth_config) + return all_auth_configs + + # Used to convert string represented boolean to a boolean type def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 365e44c1d..e443c4d28 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -7,6 +7,7 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.site_item import SiteAuthConfiguration from .exceptions import UnpopulatedPropertyError from .property_decorators import ( property_is_enum, @@ -94,6 +95,7 @@ def __init__( self.name: Optional[str] = name self.site_role: Optional[str] = site_role self.auth_setting: Optional[str] = auth_setting + self._idp_configuration_id: Optional[str] = None return None @@ -184,6 +186,18 @@ def groups(self) -> "Pager": raise UnpopulatedPropertyError(error) return self._groups() + @property + def idp_configuration_id(self) -> Optional[str]: + """ + IDP configuration id for the user. This is only available on Tableau + Cloud, 3.24 or later + """ + return self._idp_configuration_id + + @idp_configuration_id.setter + def idp_configuration_id(self, value: str) -> None: + self._idp_configuration_id = value + def _set_workbooks(self, workbooks) -> None: self._workbooks = workbooks @@ -204,8 +218,9 @@ def _parse_common_tags(self, user_xml, ns) -> "UserItem": email, auth_setting, _, + _, ) = self._parse_element(user_xml, ns) - self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None) + self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None, None) return self def _set_values( @@ -219,6 +234,7 @@ def _set_values( email, auth_setting, domain_name, + idp_configuration_id, ): if id is not None: self._id = id @@ -238,6 +254,8 @@ def _set_values( self._auth_setting = auth_setting if domain_name: self._domain_name = domain_name + if idp_configuration_id: + self._domain_name = idp_configuration_id @classmethod def from_response(cls, resp, ns) -> list["UserItem"]: @@ -265,6 +283,7 @@ def _parse_xml(cls, element_name, resp, ns): email, auth_setting, domain_name, + idp_configuration_id, ) = cls._parse_element(user_xml, ns) user_item = cls(name, site_role) user_item._set_values( @@ -277,6 +296,7 @@ def _parse_xml(cls, element_name, resp, ns): email, auth_setting, domain_name, + idp_configuration_id, ) all_user_items.append(user_item) return all_user_items @@ -295,6 +315,7 @@ def _parse_element(user_xml, ns): fullname = user_xml.get("fullName", None) email = user_xml.get("email", None) auth_setting = user_xml.get("authSetting", None) + idp_configuration_id = user_xml.get("idpConfigurationId", None) domain_name = None domain_elem = user_xml.find(".//t:domain", namespaces=ns) @@ -311,6 +332,7 @@ def _parse_element(user_xml, ns): email, auth_setting, domain_name, + idp_configuration_id, ) class CSVImport: @@ -361,6 +383,7 @@ def create_user_from_line(line: str): values[UserItem.CSVImport.ColumnType.EMAIL], values[UserItem.CSVImport.ColumnType.AUTH], None, + None, ) return user diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 55d2a5ad0..e2316fbb8 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -4,7 +4,7 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory -from tableauserverclient.models import SiteItem, PaginationItem +from tableauserverclient.models import SiteAuthConfiguration, SiteItem, PaginationItem from tableauserverclient.helpers.logging import logger @@ -418,3 +418,20 @@ def re_encrypt_extracts(self, site_id: str) -> None: empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) + + @api(version="3.24") + def list_auth_configurations(self) -> list[SiteAuthConfiguration]: + """ + Lists all authentication configurations on the current site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_site.htm#list_authentication_configurations_site + + Returns + ------- + list[SiteAuthConfiguration] + A list of authentication configurations on the current site. + """ + url = f"{self.baseurl}/{self.parent_srv.site_id}/site-auth-configurations" + server_response = self.get_request(url) + auth_configurations = SiteAuthConfiguration.from_response(server_response.content, self.parent_srv.namespace) + return auth_configurations diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 575423612..c898004f7 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -913,6 +913,8 @@ def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: user_element.attrib["authSetting"] = user_item.auth_setting if password: user_element.attrib["password"] = password + if user_item.idp_configuration_id is not None: + user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id return ET.tostring(xml_request) def add_req(self, user_item: UserItem) -> bytes: @@ -929,6 +931,9 @@ def add_req(self, user_item: UserItem) -> bytes: if user_item.auth_setting: user_element.attrib["authSetting"] = user_item.auth_setting + + if user_item.idp_configuration_id is not None: + user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id return ET.tostring(xml_request) From 65bbe1338ca94d622497e331dfb6545e3460b1e7 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 26 Apr 2025 16:50:54 -0500 Subject: [PATCH 2/7] chore: test parsing the site auth config --- test/assets/site_auth_configurations.xml | 18 ++++++++++++++++ test/test_site.py | 27 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 test/assets/site_auth_configurations.xml diff --git a/test/assets/site_auth_configurations.xml b/test/assets/site_auth_configurations.xml new file mode 100644 index 000000000..c81d179ac --- /dev/null +++ b/test/assets/site_auth_configurations.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/test/test_site.py b/test/test_site.py index 96b75f9ff..67dfb939d 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -13,6 +13,7 @@ GET_BY_NAME_XML = os.path.join(TEST_ASSET_DIR, "site_get_by_name.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "site_update.xml") CREATE_XML = os.path.join(TEST_ASSET_DIR, "site_create.xml") +SITE_AUTH_CONFIG_XML = os.path.join(TEST_ASSET_DIR, "site_auth_configurations.xml") class SiteTests(unittest.TestCase): @@ -260,3 +261,29 @@ def test_decrypt(self) -> None: with requests_mock.mock() as m: m.post(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/decrypt-extracts", status_code=200) self.server.sites.decrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") + + def test_list_auth_configurations(self) -> None: + self.server.version = "3.24" + self.baseurl = self.server.sites.baseurl + with open(SITE_AUTH_CONFIG_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + + assert self.baseurl == self.server.sites.baseurl + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{self.server.site_id}/site-auth-configurations", status_code=200, text=response_xml) + configs = self.server.sites.list_auth_configurations() + + assert len(configs) == 2, "Expected 2 auth configurations" + + assert configs[0].auth_setting =="OIDC" + assert configs[0].enabled + assert configs[0].idp_configuration_id == "00000000-0000-0000-0000-000000000000" + assert configs[0].idp_configuration_name == "Initial Salesforce" + assert configs[0].known_provider_alias == "Salesforce" + assert configs[1].auth_setting =="SAML" + assert configs[1].enabled + assert configs[1].idp_configuration_id == "11111111-1111-1111-1111-111111111111" + assert configs[1].idp_configuration_name == "Initial SAML" + assert configs[1].known_provider_alias is None + From 650de860eb9067b94f34faa7c917c1b05dbd5022 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 26 Apr 2025 16:57:06 -0500 Subject: [PATCH 3/7] test: setting user idp id --- test/test_site.py | 5 ++--- test/test_user.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/test/test_site.py b/test/test_site.py index 67dfb939d..243810254 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -276,14 +276,13 @@ def test_list_auth_configurations(self) -> None: assert len(configs) == 2, "Expected 2 auth configurations" - assert configs[0].auth_setting =="OIDC" + assert configs[0].auth_setting == "OIDC" assert configs[0].enabled assert configs[0].idp_configuration_id == "00000000-0000-0000-0000-000000000000" assert configs[0].idp_configuration_name == "Initial Salesforce" assert configs[0].known_provider_alias == "Salesforce" - assert configs[1].auth_setting =="SAML" + assert configs[1].auth_setting == "SAML" assert configs[1].enabled assert configs[1].idp_configuration_id == "11111111-1111-1111-1111-111111111111" assert configs[1].idp_configuration_name == "Initial SAML" assert configs[1].known_provider_alias is None - diff --git a/test/test_user.py b/test/test_user.py index 645adcfd5..8a14f5623 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,6 +1,7 @@ import os import unittest +from defusedxml import ElementTree as ET import requests_mock import tableauserverclient as TSC @@ -249,3 +250,20 @@ def test_get_users_from_file(self): users, failures = self.server.users.create_from_file(USERS) assert users[0].name == "Cassie", users assert failures == [] + + def test_add_user_idp_configuration(self): + with open(ADD_XML) as f: + response_xml = f.read() + user = TSC.UserItem(name="Cassie", site_role="Viewer", auth_setting="ServerDefault") + user.idp_configuration_id = "012345" + + with requests_mock.mock() as m: + m.post(self.server.users.baseurl, text=response_xml) + user = self.server.users.add(user) + + history = m.request_history[0] + + tree = ET.fromstring(history.text) + user_elem = tree.find(".//user") + assert user_elem is not None + assert user_elem.attrib["idpConfigurationId"] == "012345" From 0fd3909b2ed4f898de8fee5c8aa861cbe55a5420 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 26 Apr 2025 20:37:17 -0500 Subject: [PATCH 4/7] test: update user idp --- test/test_user.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/test_user.py b/test/test_user.py index 8a14f5623..9c13589b7 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -267,3 +267,21 @@ def test_add_user_idp_configuration(self): user_elem = tree.find(".//user") assert user_elem is not None assert user_elem.attrib["idpConfigurationId"] == "012345" + + def test_update_user_idp_configuration(self): + with open(ADD_XML) as f: + response_xml = f.read() + user = TSC.UserItem(name="Cassie", site_role="Viewer", auth_setting="ServerDefault") + user._id = "0123456789" + user.idp_configuration_id = "012345" + + with requests_mock.mock() as m: + m.put(f"{self.server.users.baseurl}/{user.id}", text=response_xml) + user = self.server.users.update(user) + + history = m.request_history[0] + + tree = ET.fromstring(history.text) + user_elem = tree.find(".//user") + assert user_elem is not None + assert user_elem.attrib["idpConfigurationId"] == "012345" From c2a4a126750e80af2813e58aac826f3f4c56840b Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 26 Apr 2025 20:45:17 -0500 Subject: [PATCH 5/7] chore: type tag idp user tests --- test/test_user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_user.py b/test/test_user.py index 9c13589b7..498732da9 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -251,7 +251,7 @@ def test_get_users_from_file(self): assert users[0].name == "Cassie", users assert failures == [] - def test_add_user_idp_configuration(self): + def test_add_user_idp_configuration(self) -> None: with open(ADD_XML) as f: response_xml = f.read() user = TSC.UserItem(name="Cassie", site_role="Viewer", auth_setting="ServerDefault") @@ -268,7 +268,7 @@ def test_add_user_idp_configuration(self): assert user_elem is not None assert user_elem.attrib["idpConfigurationId"] == "012345" - def test_update_user_idp_configuration(self): + def test_update_user_idp_configuration(self) -> None: with open(ADD_XML) as f: response_xml = f.read() user = TSC.UserItem(name="Cassie", site_role="Viewer", auth_setting="ServerDefault") From 04667f361b92bac12d47344ddc5f5e7708dd4684 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 13 May 2025 16:58:01 -0500 Subject: [PATCH 6/7] fix: typo. Don't override domain_name with IDP ID --- tableauserverclient/models/user_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index e443c4d28..5f6702b80 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -255,7 +255,7 @@ def _set_values( if domain_name: self._domain_name = domain_name if idp_configuration_id: - self._domain_name = idp_configuration_id + self._idp_configuration_id = idp_configuration_id @classmethod def from_response(cls, resp, ns) -> list["UserItem"]: From a7030f32d65c8898b18bfe404b396dec22d840c6 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 13 May 2025 17:00:46 -0500 Subject: [PATCH 7/7] fix: remove auth_setting from idp tests --- test/test_user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_user.py b/test/test_user.py index 498732da9..e258fa938 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -254,7 +254,7 @@ def test_get_users_from_file(self): def test_add_user_idp_configuration(self) -> None: with open(ADD_XML) as f: response_xml = f.read() - user = TSC.UserItem(name="Cassie", site_role="Viewer", auth_setting="ServerDefault") + user = TSC.UserItem(name="Cassie", site_role="Viewer") user.idp_configuration_id = "012345" with requests_mock.mock() as m: @@ -271,7 +271,7 @@ def test_add_user_idp_configuration(self) -> None: def test_update_user_idp_configuration(self) -> None: with open(ADD_XML) as f: response_xml = f.read() - user = TSC.UserItem(name="Cassie", site_role="Viewer", auth_setting="ServerDefault") + user = TSC.UserItem(name="Cassie", site_role="Viewer") user._id = "0123456789" user.idp_configuration_id = "012345"