Skip to content

Commit a2b1558

Browse files
authored
Add support for multiple IDPs (jorwoods)
Add support for multiple IDPs Fixes #1574 Fixes #1598 --------- Authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
1 parent a5ea333 commit a2b1558

File tree

9 files changed

+159
-3
lines changed

9 files changed

+159
-3
lines changed

tableauserverclient/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
Resource,
3636
RevisionItem,
3737
ScheduleItem,
38+
SiteAuthConfiguration,
3839
SiteItem,
3940
ServerInfoItem,
4041
SubscriptionItem,
@@ -121,6 +122,7 @@
121122
"ServerInfoItem",
122123
"ServerResponseError",
123124
"SiteItem",
125+
"SiteAuthConfiguration",
124126
"Sort",
125127
"SubscriptionItem",
126128
"TableauAuth",

tableauserverclient/models/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
from tableauserverclient.models.revision_item import RevisionItem
3636
from tableauserverclient.models.schedule_item import ScheduleItem
3737
from tableauserverclient.models.server_info_item import ServerInfoItem
38-
from tableauserverclient.models.site_item import SiteItem
38+
from tableauserverclient.models.site_item import SiteItem, SiteAuthConfiguration
3939
from tableauserverclient.models.subscription_item import SubscriptionItem
4040
from tableauserverclient.models.table_item import TableItem
4141
from tableauserverclient.models.tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth, JWTAuth
@@ -83,6 +83,7 @@
8383
"RevisionItem",
8484
"ScheduleItem",
8585
"ServerInfoItem",
86+
"SiteAuthConfiguration",
8687
"SiteItem",
8788
"SubscriptionItem",
8889
"TableItem",

tableauserverclient/models/site_item.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,34 @@ def _parse_element(site_xml, ns):
11881188
)
11891189

11901190

1191+
class SiteAuthConfiguration:
1192+
"""
1193+
Authentication configuration for a site.
1194+
"""
1195+
1196+
def __init__(self):
1197+
self.auth_setting: Optional[str] = None
1198+
self.enabled: Optional[bool] = None
1199+
self.idp_configuration_id: Optional[str] = None
1200+
self.idp_configuration_name: Optional[str] = None
1201+
self.known_provider_alias: Optional[str] = None
1202+
1203+
@classmethod
1204+
def from_response(cls, resp: bytes, ns: dict) -> list["SiteAuthConfiguration"]:
1205+
all_auth_configs = list()
1206+
parsed_response = fromstring(resp)
1207+
all_auth_xml = parsed_response.findall(".//t:siteAuthConfiguration", namespaces=ns)
1208+
for auth_xml in all_auth_xml:
1209+
auth_config = cls()
1210+
auth_config.auth_setting = auth_xml.get("authSetting", None)
1211+
auth_config.enabled = string_to_bool(auth_xml.get("enabled", ""))
1212+
auth_config.idp_configuration_id = auth_xml.get("idpConfigurationId", None)
1213+
auth_config.idp_configuration_name = auth_xml.get("idpConfigurationName", None)
1214+
auth_config.known_provider_alias = auth_xml.get("knownProviderAlias", None)
1215+
all_auth_configs.append(auth_config)
1216+
return all_auth_configs
1217+
1218+
11911219
# Used to convert string represented boolean to a boolean type
11921220
def string_to_bool(s: str) -> bool:
11931221
return s.lower() == "true"

tableauserverclient/models/user_item.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from defusedxml.ElementTree import fromstring
88

99
from tableauserverclient.datetime_helpers import parse_datetime
10+
from tableauserverclient.models.site_item import SiteAuthConfiguration
1011
from .exceptions import UnpopulatedPropertyError
1112
from .property_decorators import (
1213
property_is_enum,
@@ -94,6 +95,7 @@ def __init__(
9495
self.name: Optional[str] = name
9596
self.site_role: Optional[str] = site_role
9697
self.auth_setting: Optional[str] = auth_setting
98+
self._idp_configuration_id: Optional[str] = None
9799

98100
return None
99101

@@ -184,6 +186,18 @@ def groups(self) -> "Pager":
184186
raise UnpopulatedPropertyError(error)
185187
return self._groups()
186188

189+
@property
190+
def idp_configuration_id(self) -> Optional[str]:
191+
"""
192+
IDP configuration id for the user. This is only available on Tableau
193+
Cloud, 3.24 or later
194+
"""
195+
return self._idp_configuration_id
196+
197+
@idp_configuration_id.setter
198+
def idp_configuration_id(self, value: str) -> None:
199+
self._idp_configuration_id = value
200+
187201
def _set_workbooks(self, workbooks) -> None:
188202
self._workbooks = workbooks
189203

@@ -204,8 +218,9 @@ def _parse_common_tags(self, user_xml, ns) -> "UserItem":
204218
email,
205219
auth_setting,
206220
_,
221+
_,
207222
) = self._parse_element(user_xml, ns)
208-
self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None)
223+
self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None, None)
209224
return self
210225

211226
def _set_values(
@@ -219,6 +234,7 @@ def _set_values(
219234
email,
220235
auth_setting,
221236
domain_name,
237+
idp_configuration_id,
222238
):
223239
if id is not None:
224240
self._id = id
@@ -238,6 +254,8 @@ def _set_values(
238254
self._auth_setting = auth_setting
239255
if domain_name:
240256
self._domain_name = domain_name
257+
if idp_configuration_id:
258+
self._idp_configuration_id = idp_configuration_id
241259

242260
@classmethod
243261
def from_response(cls, resp, ns) -> list["UserItem"]:
@@ -265,6 +283,7 @@ def _parse_xml(cls, element_name, resp, ns):
265283
email,
266284
auth_setting,
267285
domain_name,
286+
idp_configuration_id,
268287
) = cls._parse_element(user_xml, ns)
269288
user_item = cls(name, site_role)
270289
user_item._set_values(
@@ -277,6 +296,7 @@ def _parse_xml(cls, element_name, resp, ns):
277296
email,
278297
auth_setting,
279298
domain_name,
299+
idp_configuration_id,
280300
)
281301
all_user_items.append(user_item)
282302
return all_user_items
@@ -295,6 +315,7 @@ def _parse_element(user_xml, ns):
295315
fullname = user_xml.get("fullName", None)
296316
email = user_xml.get("email", None)
297317
auth_setting = user_xml.get("authSetting", None)
318+
idp_configuration_id = user_xml.get("idpConfigurationId", None)
298319

299320
domain_name = None
300321
domain_elem = user_xml.find(".//t:domain", namespaces=ns)
@@ -311,6 +332,7 @@ def _parse_element(user_xml, ns):
311332
email,
312333
auth_setting,
313334
domain_name,
335+
idp_configuration_id,
314336
)
315337

316338
class CSVImport:
@@ -361,6 +383,7 @@ def create_user_from_line(line: str):
361383
values[UserItem.CSVImport.ColumnType.EMAIL],
362384
values[UserItem.CSVImport.ColumnType.AUTH],
363385
None,
386+
None,
364387
)
365388
return user
366389

tableauserverclient/server/endpoint/sites_endpoint.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from .endpoint import Endpoint, api
55
from .exceptions import MissingRequiredFieldError
66
from tableauserverclient.server import RequestFactory
7-
from tableauserverclient.models import SiteItem, PaginationItem
7+
from tableauserverclient.models import SiteAuthConfiguration, SiteItem, PaginationItem
88

99
from tableauserverclient.helpers.logging import logger
1010

@@ -418,3 +418,20 @@ def re_encrypt_extracts(self, site_id: str) -> None:
418418

419419
empty_req = RequestFactory.Empty.empty_req()
420420
self.post_request(url, empty_req)
421+
422+
@api(version="3.24")
423+
def list_auth_configurations(self) -> list[SiteAuthConfiguration]:
424+
"""
425+
Lists all authentication configurations on the current site.
426+
427+
REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_site.htm#list_authentication_configurations_site
428+
429+
Returns
430+
-------
431+
list[SiteAuthConfiguration]
432+
A list of authentication configurations on the current site.
433+
"""
434+
url = f"{self.baseurl}/{self.parent_srv.site_id}/site-auth-configurations"
435+
server_response = self.get_request(url)
436+
auth_configurations = SiteAuthConfiguration.from_response(server_response.content, self.parent_srv.namespace)
437+
return auth_configurations

tableauserverclient/server/request_factory.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,6 +913,8 @@ def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes:
913913
user_element.attrib["authSetting"] = user_item.auth_setting
914914
if password:
915915
user_element.attrib["password"] = password
916+
if user_item.idp_configuration_id is not None:
917+
user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id
916918
return ET.tostring(xml_request)
917919

918920
def add_req(self, user_item: UserItem) -> bytes:
@@ -929,6 +931,9 @@ def add_req(self, user_item: UserItem) -> bytes:
929931

930932
if user_item.auth_setting:
931933
user_element.attrib["authSetting"] = user_item.auth_setting
934+
935+
if user_item.idp_configuration_id is not None:
936+
user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id
932937
return ET.tostring(xml_request)
933938

934939

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?xml version='1.0' encoding='UTF-8'?>
2+
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.3.xsd">
3+
<siteAuthConfigurations>
4+
<siteAuthConfiguration
5+
authSetting="OIDC"
6+
enabled="true"
7+
idpConfigurationId="00000000-0000-0000-0000-000000000000"
8+
idpConfigurationName="Initial Salesforce"
9+
knownProviderAlias="Salesforce"
10+
/>
11+
<siteAuthConfiguration
12+
authSetting="SAML"
13+
enabled="true"
14+
idpConfigurationId="11111111-1111-1111-1111-111111111111"
15+
idpConfigurationName="Initial SAML"
16+
/>
17+
</siteAuthConfigurations>
18+
</tsResponse>

test/test_site.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
GET_BY_NAME_XML = os.path.join(TEST_ASSET_DIR, "site_get_by_name.xml")
1414
UPDATE_XML = os.path.join(TEST_ASSET_DIR, "site_update.xml")
1515
CREATE_XML = os.path.join(TEST_ASSET_DIR, "site_create.xml")
16+
SITE_AUTH_CONFIG_XML = os.path.join(TEST_ASSET_DIR, "site_auth_configurations.xml")
1617

1718

1819
class SiteTests(unittest.TestCase):
@@ -260,3 +261,28 @@ def test_decrypt(self) -> None:
260261
with requests_mock.mock() as m:
261262
m.post(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/decrypt-extracts", status_code=200)
262263
self.server.sites.decrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f")
264+
265+
def test_list_auth_configurations(self) -> None:
266+
self.server.version = "3.24"
267+
self.baseurl = self.server.sites.baseurl
268+
with open(SITE_AUTH_CONFIG_XML, "rb") as f:
269+
response_xml = f.read().decode("utf-8")
270+
271+
assert self.baseurl == self.server.sites.baseurl
272+
273+
with requests_mock.mock() as m:
274+
m.get(f"{self.baseurl}/{self.server.site_id}/site-auth-configurations", status_code=200, text=response_xml)
275+
configs = self.server.sites.list_auth_configurations()
276+
277+
assert len(configs) == 2, "Expected 2 auth configurations"
278+
279+
assert configs[0].auth_setting == "OIDC"
280+
assert configs[0].enabled
281+
assert configs[0].idp_configuration_id == "00000000-0000-0000-0000-000000000000"
282+
assert configs[0].idp_configuration_name == "Initial Salesforce"
283+
assert configs[0].known_provider_alias == "Salesforce"
284+
assert configs[1].auth_setting == "SAML"
285+
assert configs[1].enabled
286+
assert configs[1].idp_configuration_id == "11111111-1111-1111-1111-111111111111"
287+
assert configs[1].idp_configuration_name == "Initial SAML"
288+
assert configs[1].known_provider_alias is None

test/test_user.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
import unittest
33

4+
from defusedxml import ElementTree as ET
45
import requests_mock
56

67
import tableauserverclient as TSC
@@ -249,3 +250,38 @@ def test_get_users_from_file(self):
249250
users, failures = self.server.users.create_from_file(USERS)
250251
assert users[0].name == "Cassie", users
251252
assert failures == []
253+
254+
def test_add_user_idp_configuration(self) -> None:
255+
with open(ADD_XML) as f:
256+
response_xml = f.read()
257+
user = TSC.UserItem(name="Cassie", site_role="Viewer")
258+
user.idp_configuration_id = "012345"
259+
260+
with requests_mock.mock() as m:
261+
m.post(self.server.users.baseurl, text=response_xml)
262+
user = self.server.users.add(user)
263+
264+
history = m.request_history[0]
265+
266+
tree = ET.fromstring(history.text)
267+
user_elem = tree.find(".//user")
268+
assert user_elem is not None
269+
assert user_elem.attrib["idpConfigurationId"] == "012345"
270+
271+
def test_update_user_idp_configuration(self) -> None:
272+
with open(ADD_XML) as f:
273+
response_xml = f.read()
274+
user = TSC.UserItem(name="Cassie", site_role="Viewer")
275+
user._id = "0123456789"
276+
user.idp_configuration_id = "012345"
277+
278+
with requests_mock.mock() as m:
279+
m.put(f"{self.server.users.baseurl}/{user.id}", text=response_xml)
280+
user = self.server.users.update(user)
281+
282+
history = m.request_history[0]
283+
284+
tree = ET.fromstring(history.text)
285+
user_elem = tree.find(".//user")
286+
assert user_elem is not None
287+
assert user_elem.attrib["idpConfigurationId"] == "012345"

0 commit comments

Comments
 (0)