diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 957a820d..538f8522 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 e4131b72..10c3149f 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 e4e146f9..ab65b97b 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 365e44c1..5f6702b8 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._idp_configuration_id = 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 55d2a5ad..e2316fbb 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 57542361..c898004f 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) diff --git a/test/assets/site_auth_configurations.xml b/test/assets/site_auth_configurations.xml new file mode 100644 index 00000000..c81d179a --- /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 96b75f9f..24381025 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,28 @@ 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 diff --git a/test/test_user.py b/test/test_user.py index 645adcfd..e258fa93 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,38 @@ 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) -> None: + with open(ADD_XML) as f: + response_xml = f.read() + user = TSC.UserItem(name="Cassie", site_role="Viewer") + 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" + + 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") + 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"