From f69170656b4c690d4b8243cf9d1093e430f014f0 Mon Sep 17 00:00:00 2001 From: Sushma Sreedharan Date: Mon, 12 Oct 2026 18:06:53 -0700 Subject: [PATCH 1/6] @W-24145082 - Add WIF workgroupAuthMode and authFederationServiceAccount to connection update The Tableau REST API's update-connection endpoints for workbooks and datasources (REST API 3.30+) accept two new XML attributes on payloads for Workload Identity Federation: - workgroupAuthMode: "workload-identity-federation" (direct, e.g. Snowflake) or "workload-identity-federation-impersonation" (impersonation, e.g. BigQuery) - authFederationServiceAccount: service account email, required for impersonation only Expose both as string properties on ConnectionItem and emit them from RequestFactory.Connection.update_req (the shared builder used by workbooks, datasources, and flows for both single and bulk variants). Also expose two constants on the public API: WORKGROUP_AUTH_MODE_WIF, WORKGROUP_AUTH_MODE_WIF_IMPERSONATION. Tests cover direct and impersonation variants against workbook and datasource update-connection endpoints. Co-Authored-By: Claude Opus 4.7 (1M context) --- tableauserverclient/__init__.py | 4 ++ tableauserverclient/models/__init__.py | 8 +++- tableauserverclient/models/connection_item.py | 27 +++++++++++ tableauserverclient/server/request_factory.py | 4 ++ .../datasource_update_connection_wif.xml | 3 ++ .../assets/workbook_update_connection_wif.xml | 3 ++ test/test_datasource.py | 46 +++++++++++++++++++ test/test_workbook.py | 42 +++++++++++++++++ 8 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 test/assets/datasource_update_connection_wif.xml create mode 100644 test/assets/workbook_update_connection_wif.xml diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 7241f23ca..6bba5a580 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -56,6 +56,8 @@ WebhookItem, WeeklyInterval, WorkbookItem, + WORKGROUP_AUTH_MODE_WIF, + WORKGROUP_AUTH_MODE_WIF_IMPERSONATION, ) from tableauserverclient.server import ( @@ -147,4 +149,6 @@ "WebhookItem", "WeeklyInterval", "WorkbookItem", + "WORKGROUP_AUTH_MODE_WIF", + "WORKGROUP_AUTH_MODE_WIF_IMPERSONATION", ] diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index aa28e0dbf..bf2cfdc64 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,7 +1,11 @@ from tableauserverclient.models.collection_item import CollectionItem from tableauserverclient.models.column_item import ColumnItem from tableauserverclient.models.connection_credentials import ConnectionCredentials -from tableauserverclient.models.connection_item import ConnectionItem +from tableauserverclient.models.connection_item import ( + ConnectionItem, + WORKGROUP_AUTH_MODE_WIF, + WORKGROUP_AUTH_MODE_WIF_IMPERSONATION, +) from tableauserverclient.models.custom_view_item import CustomViewItem from tableauserverclient.models.data_acceleration_report_item import DataAccelerationReportItem from tableauserverclient.models.data_alert_item import DataAlertItem @@ -117,4 +121,6 @@ "ExtensionsServer", "ExtensionsSiteSettings", "SafeExtension", + "WORKGROUP_AUTH_MODE_WIF", + "WORKGROUP_AUTH_MODE_WIF_IMPERSONATION", ] diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index fa4b8cf9f..90bdec9f8 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -6,6 +6,11 @@ from .property_decorators import property_is_boolean from tableauserverclient.helpers.logging import logger +# Workload Identity Federation (WIF) auth-mode values for ConnectionItem.workgroup_auth_mode. +# Requires server REST API 3.30+. +WORKGROUP_AUTH_MODE_WIF = "workload-identity-federation" +WORKGROUP_AUTH_MODE_WIF_IMPERSONATION = "workload-identity-federation-impersonation" + class ConnectionItem: """ @@ -66,6 +71,8 @@ def __init__(self): self._query_tagging: bool | None = None self._auth_type: str | None = None self._database_name: str | None = None + self._workgroup_auth_mode: str | None = None + self._auth_federation_service_account: str | None = None @property def datasource_id(self) -> str | None: @@ -106,6 +113,22 @@ def auth_type(self) -> str | None: def auth_type(self, value: str | None): self._auth_type = value + @property + def workgroup_auth_mode(self) -> str | None: + return self._workgroup_auth_mode + + @workgroup_auth_mode.setter + def workgroup_auth_mode(self, value: str | None): + self._workgroup_auth_mode = value + + @property + def auth_federation_service_account(self) -> str | None: + return self._auth_federation_service_account + + @auth_federation_service_account.setter + def auth_federation_service_account(self, value: str | None): + self._auth_federation_service_account = value + @property def database_name(self) -> str | None: return self._database_name @@ -136,6 +159,10 @@ def from_response(cls, resp, ns) -> list["ConnectionItem"]: string_to_bool(s) if (s := connection_xml.get("queryTagging", None)) else None ) connection_item._auth_type = connection_xml.get("authenticationType", None) + connection_item._workgroup_auth_mode = connection_xml.get("workgroupAuthMode", None) + connection_item._auth_federation_service_account = connection_xml.get( + "authFederationServiceAccount", None + ) # The REST API GET /connections response uses "dbName" for the database # name attribute. This is different from the publish request body, which # uses "databaseName" (see _add_connections_element in request_factory.py). diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 3301b3b89..558a04376 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1226,6 +1226,10 @@ def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") connection_element.attrib["embedPassword"] = str(connection_item.embed_password).lower() if connection_item.query_tagging is not None: connection_element.attrib["queryTaggingEnabled"] = str(connection_item.query_tagging).lower() + if connection_item.workgroup_auth_mode is not None: + connection_element.attrib["workgroupAuthMode"] = connection_item.workgroup_auth_mode + if connection_item.auth_federation_service_account is not None: + connection_element.attrib["authFederationServiceAccount"] = connection_item.auth_federation_service_account class TaskRequest: diff --git a/test/assets/datasource_update_connection_wif.xml b/test/assets/datasource_update_connection_wif.xml new file mode 100644 index 000000000..346b7e267 --- /dev/null +++ b/test/assets/datasource_update_connection_wif.xml @@ -0,0 +1,3 @@ + + + diff --git a/test/assets/workbook_update_connection_wif.xml b/test/assets/workbook_update_connection_wif.xml new file mode 100644 index 000000000..c20c9054c --- /dev/null +++ b/test/assets/workbook_update_connection_wif.xml @@ -0,0 +1,3 @@ + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index 95332c6db..04114659c 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -36,6 +36,7 @@ UPDATE_CONNECTION_XML = TEST_ASSET_DIR / "datasource_connection_update.xml" UPDATE_CONNECTIONS_XML = TEST_ASSET_DIR / "datasource_connections_update.xml" UPDATE_CONNECTIONS_NO_AUTH_XML = TEST_ASSET_DIR / "datasource_connections_update_no_auth.xml" +UPDATE_CONNECTION_WIF_XML = TEST_ASSET_DIR / "datasource_update_connection_wif.xml" REFRESH_DUPLICATE_XML = TEST_ASSET_DIR / "datasource_refresh_duplicate.xml" @@ -237,6 +238,51 @@ def test_update_connection(server) -> None: assert "foo" == new_connection.username +def test_update_connection_wif_direct(server) -> None: + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + connection_id = "be786ae0-d2bf-4a4b-9b34-e2de8d2d4488" + response_xml = UPDATE_CONNECTION_WIF_XML.read_text() + + with requests_mock.mock() as m: + m.put(f"{server.datasources.baseurl}/{datasource_id}/connections/{connection_id}", text=response_xml) + datasource = TSC.DatasourceItem(datasource_id) + datasource._id = datasource_id + connection = TSC.ConnectionItem() + connection._id = connection_id + connection.workgroup_auth_mode = TSC.WORKGROUP_AUTH_MODE_WIF + server.datasources.update_connection(datasource, connection) + + request = m.request_history[0] + + xml_connection = fromstring(request.body).find(".//connection") + assert xml_connection is not None + assert xml_connection.get("workgroupAuthMode") == "workload-identity-federation" + assert xml_connection.get("authFederationServiceAccount") is None + + +def test_update_connection_wif_impersonation(server) -> None: + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + connection_id = "be786ae0-d2bf-4a4b-9b34-e2de8d2d4488" + response_xml = UPDATE_CONNECTION_WIF_XML.read_text() + + with requests_mock.mock() as m: + m.put(f"{server.datasources.baseurl}/{datasource_id}/connections/{connection_id}", text=response_xml) + datasource = TSC.DatasourceItem(datasource_id) + datasource._id = datasource_id + connection = TSC.ConnectionItem() + connection._id = connection_id + connection.workgroup_auth_mode = TSC.WORKGROUP_AUTH_MODE_WIF_IMPERSONATION + connection.auth_federation_service_account = "sa@proj.iam.gserviceaccount.com" + server.datasources.update_connection(datasource, connection) + + request = m.request_history[0] + + xml_connection = fromstring(request.body).find(".//connection") + assert xml_connection is not None + assert xml_connection.get("workgroupAuthMode") == "workload-identity-federation-impersonation" + assert xml_connection.get("authFederationServiceAccount") == "sa@proj.iam.gserviceaccount.com" + + def test_update_connections(server) -> None: populate_xml = POPULATE_CONNECTIONS_XML.read_text() response_xml = UPDATE_CONNECTIONS_XML.read_text() diff --git a/test/test_workbook.py b/test/test_workbook.py index 7f4d80041..48d0fdc1f 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -40,6 +40,7 @@ UPDATE_PERMISSIONS = TEST_ASSET_DIR / "workbook_update_permissions.xml" UPDATE_CONNECTIONS_XML = TEST_ASSET_DIR / "workbook_update_connections.xml" UPDATE_CONNECTIONS_NO_AUTH_XML = TEST_ASSET_DIR / "workbook_update_connections_no_auth.xml" +UPDATE_CONNECTION_WIF_XML = TEST_ASSET_DIR / "workbook_update_connection_wif.xml" @pytest.fixture(scope="function") @@ -1075,6 +1076,47 @@ def test_odata_connection(server: TSC.Server) -> None: assert xml_connection.get("serverAddress") == url +def test_update_connection_wif_direct(server: TSC.Server) -> None: + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + connection = TSC.ConnectionItem() + connection._id = "17376070-64d1-4d17-acb4-a56e4b5b1768" + connection.workgroup_auth_mode = TSC.WORKGROUP_AUTH_MODE_WIF + response_xml = UPDATE_CONNECTION_WIF_XML.read_text() + + with requests_mock.mock() as m: + m.put(f"{server.workbooks.baseurl}/{workbook.id}/connections/{connection.id}", text=response_xml) + server.workbooks.update_connection(workbook, connection) + + request = m.request_history[0] + + xml_connection = fromstring(request.body).find(".//connection") + assert xml_connection is not None + assert xml_connection.get("workgroupAuthMode") == "workload-identity-federation" + assert xml_connection.get("authFederationServiceAccount") is None + + +def test_update_connection_wif_impersonation(server: TSC.Server) -> None: + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + connection = TSC.ConnectionItem() + connection._id = "17376070-64d1-4d17-acb4-a56e4b5b1768" + connection.workgroup_auth_mode = TSC.WORKGROUP_AUTH_MODE_WIF_IMPERSONATION + connection.auth_federation_service_account = "sa@proj.iam.gserviceaccount.com" + response_xml = UPDATE_CONNECTION_WIF_XML.read_text() + + with requests_mock.mock() as m: + m.put(f"{server.workbooks.baseurl}/{workbook.id}/connections/{connection.id}", text=response_xml) + server.workbooks.update_connection(workbook, connection) + + request = m.request_history[0] + + xml_connection = fromstring(request.body).find(".//connection") + assert xml_connection is not None + assert xml_connection.get("workgroupAuthMode") == "workload-identity-federation-impersonation" + assert xml_connection.get("authFederationServiceAccount") == "sa@proj.iam.gserviceaccount.com" + + def test_update_workbook_connections(server: TSC.Server) -> None: populate_xml = POPULATE_CONNECTIONS_XML.read_text() response_xml = UPDATE_CONNECTIONS_XML.read_text() From 8b8dc0019d7ba3ba1b7088d5bb30cb48e4a58a16 Mon Sep 17 00:00:00 2001 From: Sushma Sreedharan Date: Mon, 12 Oct 2026 18:06:55 -0700 Subject: [PATCH 2/6] chore: apply black formatting Collapse auth_federation_service_account assignment onto a single line per the project's 120-char limit. Co-Authored-By: Claude Opus 4.7 (1M context) --- tableauserverclient/models/connection_item.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 90bdec9f8..862e9dee0 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -160,9 +160,7 @@ def from_response(cls, resp, ns) -> list["ConnectionItem"]: ) connection_item._auth_type = connection_xml.get("authenticationType", None) connection_item._workgroup_auth_mode = connection_xml.get("workgroupAuthMode", None) - connection_item._auth_federation_service_account = connection_xml.get( - "authFederationServiceAccount", None - ) + connection_item._auth_federation_service_account = connection_xml.get("authFederationServiceAccount", None) # The REST API GET /connections response uses "dbName" for the database # name attribute. This is different from the publish request body, which # uses "databaseName" (see _add_connections_element in request_factory.py). From 8e67cd4db2592fd96b1941f4e7eabf7c7c6380a3 Mon Sep 17 00:00:00 2001 From: Sushma Sreedharan Date: Mon, 12 Oct 2026 18:06:55 -0700 Subject: [PATCH 3/6] docs: add CHANGELOG entry for WIF connection update Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index db9780ee9..237113adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ ## Unreleased +* Added Workload Identity Federation (WIF) support to the connection update + endpoints. `ConnectionItem` now has `workgroup_auth_mode` and + `auth_federation_service_account` string properties, which are emitted on + the `` payload by `workbooks.update_connection` / + `workbooks.update_connections` and `datasources.update_connection` / + `datasources.update_connections`. Set `workgroup_auth_mode` to one of the + new module-level constants `WORKGROUP_AUTH_MODE_WIF` + (`"workload-identity-federation"`, e.g. Snowflake) or + `WORKGROUP_AUTH_MODE_WIF_IMPERSONATION` + (`"workload-identity-federation-impersonation"`, e.g. Google BigQuery); + the service account email is required only for the impersonation mode. + Requires Tableau Server REST API 3.30+ and server-side WIF to be configured + and enabled on the site. * Bumped the urllib3 floor to 2.6.3 to pick up the fix for CVE-2026-21441 (GHSA-38jv-5279-wg99, 8.9 High): urllib3's streaming decompression safeguards were bypassed when HTTP redirects were followed. TSC's manual From 550789579a2a4b63c4927521bd46e608f08a6ec1 Mon Sep 17 00:00:00 2001 From: Sushma Sreedharan Date: Thu, 15 Oct 2026 13:44:30 -0700 Subject: [PATCH 4/6] fix: use googlebigquery and add WIF response attrs in test assets Address review on #1887: datasource WIF asset was carrying the legacy "bigquery" connector class, and both WIF assets referenced a 404 schemaLocation URL. Switch the datasource asset to the current "googlebigquery" class, drop the broken schemaLocation reference, and populate both WIF response assets with workgroupAuthMode (and authFederationServiceAccount where impersonation applies) so the new from_response coverage actually exercises WIF parsing. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/assets/datasource_update_connection_wif.xml | 5 +++-- test/assets/workbook_update_connection_wif.xml | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/test/assets/datasource_update_connection_wif.xml b/test/assets/datasource_update_connection_wif.xml index 346b7e267..e2111e68f 100644 --- a/test/assets/datasource_update_connection_wif.xml +++ b/test/assets/datasource_update_connection_wif.xml @@ -1,3 +1,4 @@ - - + + + diff --git a/test/assets/workbook_update_connection_wif.xml b/test/assets/workbook_update_connection_wif.xml index c20c9054c..289a62619 100644 --- a/test/assets/workbook_update_connection_wif.xml +++ b/test/assets/workbook_update_connection_wif.xml @@ -1,3 +1,4 @@ - - + + + From 3e70623ef2827866218115dff683ea7a9578866f Mon Sep 17 00:00:00 2001 From: Sushma Sreedharan Date: Thu, 15 Oct 2026 13:44:47 -0700 Subject: [PATCH 5/6] feat: nest WIF value set under ConnectionItem and extend to bulk and flows Address review on #1887: * Replace the two module-level WORKGROUP_AUTH_MODE_WIF* constants with a nested value set at ConnectionItem.WorkgroupAuthMode (WIF / WIF_IMPERSONATION) and drop the top-level reexports from tableauserverclient and tableauserverclient.models. The nested class carries the usage rules (resend on every update, no service account with direct mode, auth_type unset or "oauth", server echo caveat). * Extend the two bulk endpoints to really support WIF: workbooks.update_connections and datasources.update_connections now accept workgroup_auth_mode and auth_federation_service_account keyword arguments, and both Workbook.update_connections_req and Datasource.update_connections_req builders emit them on the shared payload applied to every connection LUID. * Document on flows.update_connection that WIF fields on the ConnectionItem reach the endpoint via the shared Connection.update_req builder and that server-side handling on flow connections may differ from workbook / datasource endpoints. * Rewrite the WIF test suite to cover: parametrized direct and impersonation happy paths, OAuth to WIF switch, cleared-fields non emission, service-account-without-mode permissive emit, from_response WIF parsing, from_response absent (older servers), and bulk WIF forwarding. Add a flow WIF test exercising the shared builder reach. Co-Authored-By: Claude Opus 4.7 (1M context) --- tableauserverclient/__init__.py | 4 - tableauserverclient/models/__init__.py | 8 +- tableauserverclient/models/connection_item.py | 47 ++++- .../server/endpoint/datasources_endpoint.py | 18 ++ .../server/endpoint/flows_endpoint.py | 10 ++ .../server/endpoint/workbooks_endpoint.py | 18 ++ tableauserverclient/server/request_factory.py | 16 ++ test/test_datasource.py | 159 +++++++++++++++- test/test_flow.py | 37 ++++ test/test_workbook.py | 169 +++++++++++++++++- 10 files changed, 458 insertions(+), 28 deletions(-) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 6bba5a580..7241f23ca 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -56,8 +56,6 @@ WebhookItem, WeeklyInterval, WorkbookItem, - WORKGROUP_AUTH_MODE_WIF, - WORKGROUP_AUTH_MODE_WIF_IMPERSONATION, ) from tableauserverclient.server import ( @@ -149,6 +147,4 @@ "WebhookItem", "WeeklyInterval", "WorkbookItem", - "WORKGROUP_AUTH_MODE_WIF", - "WORKGROUP_AUTH_MODE_WIF_IMPERSONATION", ] diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index bf2cfdc64..aa28e0dbf 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,11 +1,7 @@ from tableauserverclient.models.collection_item import CollectionItem from tableauserverclient.models.column_item import ColumnItem from tableauserverclient.models.connection_credentials import ConnectionCredentials -from tableauserverclient.models.connection_item import ( - ConnectionItem, - WORKGROUP_AUTH_MODE_WIF, - WORKGROUP_AUTH_MODE_WIF_IMPERSONATION, -) +from tableauserverclient.models.connection_item import ConnectionItem from tableauserverclient.models.custom_view_item import CustomViewItem from tableauserverclient.models.data_acceleration_report_item import DataAccelerationReportItem from tableauserverclient.models.data_alert_item import DataAlertItem @@ -121,6 +117,4 @@ "ExtensionsServer", "ExtensionsSiteSettings", "SafeExtension", - "WORKGROUP_AUTH_MODE_WIF", - "WORKGROUP_AUTH_MODE_WIF_IMPERSONATION", ] diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 862e9dee0..29945e569 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -6,11 +6,6 @@ from .property_decorators import property_is_boolean from tableauserverclient.helpers.logging import logger -# Workload Identity Federation (WIF) auth-mode values for ConnectionItem.workgroup_auth_mode. -# Requires server REST API 3.30+. -WORKGROUP_AUTH_MODE_WIF = "workload-identity-federation" -WORKGROUP_AUTH_MODE_WIF_IMPERSONATION = "workload-identity-federation-impersonation" - class ConnectionItem: """ @@ -55,8 +50,50 @@ class ConnectionItem: database_name: str The name of the database for the connection. + + workgroup_auth_mode: str + Workload Identity Federation (WIF) auth mode. Set to one of the + values on ``ConnectionItem.WorkgroupAuthMode`` to switch an existing + connection to WIF on an update. Requires Tableau Server 26.3 / REST + API 3.30+ with WIF configured and enabled on the site. See the + ``WorkgroupAuthMode`` nested class for usage notes. The server does + not echo this attribute on GET responses today, so values parsed + from a response are typically ``None``. + + auth_federation_service_account: str + Service-account email required by the server for the + ``WorkgroupAuthMode.WIF_IMPERSONATION`` mode (e.g. Google BigQuery) + and rejected for the direct ``WorkgroupAuthMode.WIF`` mode (e.g. + Snowflake). Also requires REST API 3.30+. Not echoed on GET today, + so typically ``None`` when parsed from a response. """ + class WorkgroupAuthMode: + """ + Workload Identity Federation (WIF) auth-mode values for + :attr:`ConnectionItem.workgroup_auth_mode`. + + Requires Tableau Server 26.3 / REST API 3.30+ with WIF configured + and enabled on the site. Older servers silently ignore unknown + connection attributes, so emitting these against an older server is + a no-op rather than an error. + + Usage notes: + + - Send ``workgroup_auth_mode`` on every subsequent update of a WIF + connection. Once a connection has been switched to WIF, omitting + the attribute on a later update returns HTTP 400. + - Leave :attr:`ConnectionItem.auth_type` unset (or ``"oauth"``) when + sending a WIF mode; the server derives the stored auth type from + the mode. + - Do not set :attr:`ConnectionItem.auth_federation_service_account` + with the direct ``WIF`` mode (the server rejects it). Set it only + with ``WIF_IMPERSONATION``. + """ + + WIF = "workload-identity-federation" + WIF_IMPERSONATION = "workload-identity-federation-impersonation" + def __init__(self): self._datasource_id: str | None = None self._datasource_name: str | None = None diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index f5212485a..ac2834d06 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -396,6 +396,8 @@ def update_connections( username: str | None = None, password: str | None = None, embed_password: bool | None = None, + workgroup_auth_mode: str | None = None, + auth_federation_service_account: str | None = None, ) -> list[ConnectionItem]: """ Bulk updates one or more datasource connections by LUID. @@ -424,6 +426,20 @@ def update_connections( embed_password : bool, optional Whether to embed the password. + workgroup_auth_mode : str, optional + Workload Identity Federation (WIF) auth mode. See + ``ConnectionItem.WorkgroupAuthMode`` for allowed values and + usage notes. Applied to every connection LUID in this call. + Requires Tableau Server REST API 3.30+ with WIF configured on + the site. + + auth_federation_service_account : str, optional + Service-account email for WIF impersonation. Required with + ``ConnectionItem.WorkgroupAuthMode.WIF_IMPERSONATION`` and + rejected by the server with the direct + ``ConnectionItem.WorkgroupAuthMode.WIF`` mode. TSC does not + validate the combination client-side. + Returns ------- Iterable of str @@ -438,6 +454,8 @@ def update_connections( username=username, password=password, embed_password=embed_password, + workgroup_auth_mode=workgroup_auth_mode, + auth_federation_service_account=auth_federation_service_account, ) server_response = self.put_request(url, request_body) connection_items = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 8595006b8..c87d5570b 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -264,6 +264,16 @@ def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#update_flow_connection + Setting ``connection_item.workgroup_auth_mode`` and + ``connection_item.auth_federation_service_account`` (see + ``ConnectionItem.WorkgroupAuthMode``) will forward the Workload + Identity Federation attributes to this endpoint via the shared + ``RequestFactory.Connection.update_req`` builder. Server-side + handling of these attributes on flow connections is governed by + the flow endpoint and may differ from the workbook / datasource + endpoints — review the REST reference for flow-specific semantics + before relying on it in production. + Parameters ---------- flow_item: FlowItem diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 8310d58b3..ada961af1 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -343,6 +343,8 @@ def update_connections( username: str | None = None, password: str | None = None, embed_password: bool | None = None, + workgroup_auth_mode: str | None = None, + auth_federation_service_account: str | None = None, ) -> list[ConnectionItem]: """ Bulk updates one or more workbook connections by LUID. @@ -371,6 +373,20 @@ def update_connections( embed_password : bool, optional Whether to embed the password. + workgroup_auth_mode : str, optional + Workload Identity Federation (WIF) auth mode. See + ``ConnectionItem.WorkgroupAuthMode`` for allowed values and + usage notes. Applied to every connection LUID in this call. + Requires Tableau Server REST API 3.30+ with WIF configured on + the site. + + auth_federation_service_account : str, optional + Service-account email for WIF impersonation. Required with + ``ConnectionItem.WorkgroupAuthMode.WIF_IMPERSONATION`` and + rejected by the server with the direct + ``ConnectionItem.WorkgroupAuthMode.WIF`` mode. TSC does not + validate the combination client-side. + Returns ------- Iterable of str @@ -385,6 +401,8 @@ def update_connections( username=username, password=password, embed_password=embed_password, + workgroup_auth_mode=workgroup_auth_mode, + auth_federation_service_account=auth_federation_service_account, ) # Send request diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 558a04376..ca097fbc9 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -257,6 +257,8 @@ def update_connections_req( username: str | None = None, password: str | None = None, embed_password: bool | None = None, + workgroup_auth_mode: str | None = None, + auth_federation_service_account: str | None = None, ): conn_luids_elem = ET.SubElement(element, "connectionLUIDs") for luid in connection_luids: @@ -275,6 +277,12 @@ def update_connections_req( if embed_password is not None: connection_elem.set("embedPassword", str(embed_password).lower()) + if workgroup_auth_mode is not None: + connection_elem.set("workgroupAuthMode", workgroup_auth_mode) + + if auth_federation_service_account is not None: + connection_elem.set("authFederationServiceAccount", auth_federation_service_account) + class DQWRequest: def add_req(self, dqw_item): @@ -1184,6 +1192,8 @@ def update_connections_req( username: str | None = None, password: str | None = None, embed_password: bool | None = None, + workgroup_auth_mode: str | None = None, + auth_federation_service_account: str | None = None, ): conn_luids_elem = ET.SubElement(element, "connectionLUIDs") for luid in connection_luids: @@ -1202,6 +1212,12 @@ def update_connections_req( if embed_password is not None: connection_elem.set("embedPassword", str(embed_password).lower()) + if workgroup_auth_mode is not None: + connection_elem.set("workgroupAuthMode", workgroup_auth_mode) + + if auth_federation_service_account is not None: + connection_elem.set("authFederationServiceAccount", auth_federation_service_account) + class Connection: @_tsrequest_wrapped diff --git a/test/test_datasource.py b/test/test_datasource.py index 04114659c..824142c11 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -238,7 +238,20 @@ def test_update_connection(server) -> None: assert "foo" == new_connection.username -def test_update_connection_wif_direct(server) -> None: +@pytest.mark.parametrize( + "workgroup_auth_mode, service_account, expected_mode, expected_sa", + [ + (TSC.ConnectionItem.WorkgroupAuthMode.WIF, None, "workload-identity-federation", None), + ( + TSC.ConnectionItem.WorkgroupAuthMode.WIF_IMPERSONATION, + "sa@proj.iam.gserviceaccount.com", + "workload-identity-federation-impersonation", + "sa@proj.iam.gserviceaccount.com", + ), + ], + ids=["direct", "impersonation"], +) +def test_update_connection_wif(server, workgroup_auth_mode, service_account, expected_mode, expected_sa) -> None: datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" connection_id = "be786ae0-d2bf-4a4b-9b34-e2de8d2d4488" response_xml = UPDATE_CONNECTION_WIF_XML.read_text() @@ -249,18 +262,21 @@ def test_update_connection_wif_direct(server) -> None: datasource._id = datasource_id connection = TSC.ConnectionItem() connection._id = connection_id - connection.workgroup_auth_mode = TSC.WORKGROUP_AUTH_MODE_WIF + connection.workgroup_auth_mode = workgroup_auth_mode + if service_account is not None: + connection.auth_federation_service_account = service_account server.datasources.update_connection(datasource, connection) request = m.request_history[0] xml_connection = fromstring(request.body).find(".//connection") assert xml_connection is not None - assert xml_connection.get("workgroupAuthMode") == "workload-identity-federation" - assert xml_connection.get("authFederationServiceAccount") is None + assert xml_connection.get("workgroupAuthMode") == expected_mode + assert xml_connection.get("authFederationServiceAccount") == expected_sa -def test_update_connection_wif_impersonation(server) -> None: +def test_update_connection_wif_switch_from_oauth(server) -> None: + """OAuth -> WIF: WIF attrs ride alongside the existing auth_type.""" datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" connection_id = "be786ae0-d2bf-4a4b-9b34-e2de8d2d4488" response_xml = UPDATE_CONNECTION_WIF_XML.read_text() @@ -271,7 +287,8 @@ def test_update_connection_wif_impersonation(server) -> None: datasource._id = datasource_id connection = TSC.ConnectionItem() connection._id = connection_id - connection.workgroup_auth_mode = TSC.WORKGROUP_AUTH_MODE_WIF_IMPERSONATION + connection.auth_type = "oauth" + connection.workgroup_auth_mode = TSC.ConnectionItem.WorkgroupAuthMode.WIF_IMPERSONATION connection.auth_federation_service_account = "sa@proj.iam.gserviceaccount.com" server.datasources.update_connection(datasource, connection) @@ -279,10 +296,140 @@ def test_update_connection_wif_impersonation(server) -> None: xml_connection = fromstring(request.body).find(".//connection") assert xml_connection is not None + assert xml_connection.get("authenticationType") == "oauth" assert xml_connection.get("workgroupAuthMode") == "workload-identity-federation-impersonation" assert xml_connection.get("authFederationServiceAccount") == "sa@proj.iam.gserviceaccount.com" +def test_update_connection_wif_cleared_fields_omit_attrs(server) -> None: + """Unset WIF fields -> no WIF attrs on wire; revert is a server-side decision.""" + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + connection_id = "be786ae0-d2bf-4a4b-9b34-e2de8d2d4488" + response_xml = UPDATE_CONNECTION_WIF_XML.read_text() + + with requests_mock.mock() as m: + m.put(f"{server.datasources.baseurl}/{datasource_id}/connections/{connection_id}", text=response_xml) + datasource = TSC.DatasourceItem(datasource_id) + datasource._id = datasource_id + connection = TSC.ConnectionItem() + connection._id = connection_id + connection.auth_type = "oauth" + server.datasources.update_connection(datasource, connection) + + request = m.request_history[0] + + xml_connection = fromstring(request.body).find(".//connection") + assert xml_connection is not None + assert xml_connection.get("authenticationType") == "oauth" + assert xml_connection.get("workgroupAuthMode") is None + assert xml_connection.get("authFederationServiceAccount") is None + + +def test_update_connection_wif_service_account_without_mode(server) -> None: + """TSC is permissive: a lone service account emits as-is and the server rejects it.""" + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + connection_id = "be786ae0-d2bf-4a4b-9b34-e2de8d2d4488" + response_xml = UPDATE_CONNECTION_WIF_XML.read_text() + + with requests_mock.mock() as m: + m.put(f"{server.datasources.baseurl}/{datasource_id}/connections/{connection_id}", text=response_xml) + datasource = TSC.DatasourceItem(datasource_id) + datasource._id = datasource_id + connection = TSC.ConnectionItem() + connection._id = connection_id + connection.auth_federation_service_account = "sa@proj.iam.gserviceaccount.com" + server.datasources.update_connection(datasource, connection) + + request = m.request_history[0] + + xml_connection = fromstring(request.body).find(".//connection") + assert xml_connection is not None + assert xml_connection.get("workgroupAuthMode") is None + assert xml_connection.get("authFederationServiceAccount") == "sa@proj.iam.gserviceaccount.com" + + +def test_update_connection_wif_from_response(server) -> None: + """When the server echoes WIF attrs, ConnectionItem.from_response surfaces them.""" + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + connection_id = "be786ae0-d2bf-4a4b-9b34-e2de8d2d4488" + response_xml = UPDATE_CONNECTION_WIF_XML.read_text() + + with requests_mock.mock() as m: + m.put(f"{server.datasources.baseurl}/{datasource_id}/connections/{connection_id}", text=response_xml) + datasource = TSC.DatasourceItem(datasource_id) + datasource._id = datasource_id + connection = TSC.ConnectionItem() + connection._id = connection_id + updated = server.datasources.update_connection(datasource, connection) + + assert updated is not None + assert updated.workgroup_auth_mode == "workload-identity-federation-impersonation" + assert updated.auth_federation_service_account == "sa@proj.iam.gserviceaccount.com" + + +def test_update_connection_wif_from_response_absent(server) -> None: + """Older servers (REST < 3.30) don't echo WIF attrs; from_response parses them as None.""" + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + connection_id = "be786ae0-d2bf-4a4b-9b34-e2de8d2d4488" + response_xml = UPDATE_CONNECTION_XML.read_text() + + with requests_mock.mock() as m: + m.put(f"{server.datasources.baseurl}/{datasource_id}/connections/{connection_id}", text=response_xml) + datasource = TSC.DatasourceItem(datasource_id) + datasource._id = datasource_id + connection = TSC.ConnectionItem() + connection._id = connection_id + updated = server.datasources.update_connection(datasource, connection) + + assert updated is not None + assert updated.workgroup_auth_mode is None + assert updated.auth_federation_service_account is None + + +@pytest.mark.parametrize( + "workgroup_auth_mode, service_account, expected_mode, expected_sa", + [ + (TSC.ConnectionItem.WorkgroupAuthMode.WIF, None, "workload-identity-federation", None), + ( + TSC.ConnectionItem.WorkgroupAuthMode.WIF_IMPERSONATION, + "sa@proj.iam.gserviceaccount.com", + "workload-identity-federation-impersonation", + "sa@proj.iam.gserviceaccount.com", + ), + ], + ids=["direct", "impersonation"], +) +def test_update_connections_wif(server, workgroup_auth_mode, service_account, expected_mode, expected_sa) -> None: + """Bulk update_connections forwards WIF kwargs onto the single payload.""" + response_xml = UPDATE_CONNECTIONS_XML.read_text() + + with requests_mock.Mocker() as m: + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + connection_luids = ["be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", "a1b2c3d4-e5f6-7a8b-9c0d-123456789abc"] + + datasource = TSC.DatasourceItem(datasource_id) + datasource._id = datasource_id + server.version = "3.26" + m.put( + "http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", + text=response_xml, + ) + + server.datasources.update_connections( + datasource_item=datasource, + connection_luids=connection_luids, + workgroup_auth_mode=workgroup_auth_mode, + auth_federation_service_account=service_account, + ) + + request = m.request_history[0] + + xml_connection = fromstring(request.body).find(".//connection") + assert xml_connection is not None + assert xml_connection.get("workgroupAuthMode") == expected_mode + assert xml_connection.get("authFederationServiceAccount") == expected_sa + + def test_update_connections(server) -> None: populate_xml = POPULATE_CONNECTIONS_XML.read_text() response_xml = UPDATE_CONNECTIONS_XML.read_text() diff --git a/test/test_flow.py b/test/test_flow.py index e0275dcc9..f8c32d03d 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -4,6 +4,7 @@ import requests_mock import tempfile +from defusedxml.ElementTree import fromstring import pytest import tableauserverclient as TSC @@ -129,6 +130,42 @@ def test_populate_connections(server: TSC.Server) -> None: assert conn3.embed_password is True +def test_update_connection_wif(server: TSC.Server) -> None: + """Flow update_connection reaches the shared Connection.update_req builder, + so WIF fields on the ConnectionItem land on the payload. The + server-side handling of these attributes on flow connections is governed + by the flow endpoint and is not asserted here. + """ + flow_id = "587daa37-b84d-4400-a9a2-aa90e0be7837" + connection_id = "405c1e4b-60c9-499f-9c47-a4ef1af69359" + response_xml = ( + "" + '' + f'' + "" + ) + + with requests_mock.mock() as m: + m.put(f"{server.flows.baseurl}/{flow_id}/connections/{connection_id}", text=response_xml) + flow = TSC.FlowItem("test", "aa23f4ac-906f-11e9-86fb-3f0f71412e77") + flow._id = flow_id + connection = TSC.ConnectionItem() + connection._id = connection_id + connection.workgroup_auth_mode = TSC.ConnectionItem.WorkgroupAuthMode.WIF + updated = server.flows.update_connection(flow, connection) + + request = m.request_history[0] + + xml_connection = fromstring(request.body).find(".//connection") + assert xml_connection is not None + assert xml_connection.get("workgroupAuthMode") == "workload-identity-federation" + assert xml_connection.get("authFederationServiceAccount") is None + assert updated is not None + assert updated.workgroup_auth_mode == "workload-identity-federation" + + def test_populate_permissions(server: TSC.Server) -> None: response_xml = POPULATE_PERMISSIONS_XML.read_text() with requests_mock.mock() as m: diff --git a/test/test_workbook.py b/test/test_workbook.py index 48d0fdc1f..c3188c4c8 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -1076,12 +1076,33 @@ def test_odata_connection(server: TSC.Server) -> None: assert xml_connection.get("serverAddress") == url -def test_update_connection_wif_direct(server: TSC.Server) -> None: +@pytest.mark.parametrize( + "workgroup_auth_mode, service_account, expected_mode, expected_sa", + [ + (TSC.ConnectionItem.WorkgroupAuthMode.WIF, None, "workload-identity-federation", None), + ( + TSC.ConnectionItem.WorkgroupAuthMode.WIF_IMPERSONATION, + "sa@proj.iam.gserviceaccount.com", + "workload-identity-federation-impersonation", + "sa@proj.iam.gserviceaccount.com", + ), + ], + ids=["direct", "impersonation"], +) +def test_update_connection_wif( + server: TSC.Server, + workgroup_auth_mode: str, + service_account: str | None, + expected_mode: str, + expected_sa: str | None, +) -> None: workbook = TSC.WorkbookItem("project", "test") workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" connection = TSC.ConnectionItem() connection._id = "17376070-64d1-4d17-acb4-a56e4b5b1768" - connection.workgroup_auth_mode = TSC.WORKGROUP_AUTH_MODE_WIF + connection.workgroup_auth_mode = workgroup_auth_mode + if service_account is not None: + connection.auth_federation_service_account = service_account response_xml = UPDATE_CONNECTION_WIF_XML.read_text() with requests_mock.mock() as m: @@ -1092,16 +1113,18 @@ def test_update_connection_wif_direct(server: TSC.Server) -> None: xml_connection = fromstring(request.body).find(".//connection") assert xml_connection is not None - assert xml_connection.get("workgroupAuthMode") == "workload-identity-federation" - assert xml_connection.get("authFederationServiceAccount") is None + assert xml_connection.get("workgroupAuthMode") == expected_mode + assert xml_connection.get("authFederationServiceAccount") == expected_sa -def test_update_connection_wif_impersonation(server: TSC.Server) -> None: +def test_update_connection_wif_switch_from_oauth(server: TSC.Server) -> None: + """OAuth -> WIF: WIF attrs ride alongside the existing auth_type.""" workbook = TSC.WorkbookItem("project", "test") workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" connection = TSC.ConnectionItem() connection._id = "17376070-64d1-4d17-acb4-a56e4b5b1768" - connection.workgroup_auth_mode = TSC.WORKGROUP_AUTH_MODE_WIF_IMPERSONATION + connection.auth_type = "oauth" + connection.workgroup_auth_mode = TSC.ConnectionItem.WorkgroupAuthMode.WIF_IMPERSONATION connection.auth_federation_service_account = "sa@proj.iam.gserviceaccount.com" response_xml = UPDATE_CONNECTION_WIF_XML.read_text() @@ -1113,10 +1136,144 @@ def test_update_connection_wif_impersonation(server: TSC.Server) -> None: xml_connection = fromstring(request.body).find(".//connection") assert xml_connection is not None + assert xml_connection.get("authenticationType") == "oauth" assert xml_connection.get("workgroupAuthMode") == "workload-identity-federation-impersonation" assert xml_connection.get("authFederationServiceAccount") == "sa@proj.iam.gserviceaccount.com" +def test_update_connection_wif_cleared_fields_omit_attrs(server: TSC.Server) -> None: + """With WIF fields unset, the request omits both WIF attributes. + + Reverting an existing WIF connection to OAuth is a server-side decision + (REST reference: resend workgroupAuthMode on every WIF update); TSC + simply does not emit the attribute when the ConnectionItem field is + unset. + """ + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + connection = TSC.ConnectionItem() + connection._id = "17376070-64d1-4d17-acb4-a56e4b5b1768" + connection.auth_type = "oauth" + response_xml = UPDATE_CONNECTION_WIF_XML.read_text() + + with requests_mock.mock() as m: + m.put(f"{server.workbooks.baseurl}/{workbook.id}/connections/{connection.id}", text=response_xml) + server.workbooks.update_connection(workbook, connection) + + request = m.request_history[0] + + xml_connection = fromstring(request.body).find(".//connection") + assert xml_connection is not None + assert xml_connection.get("authenticationType") == "oauth" + assert xml_connection.get("workgroupAuthMode") is None + assert xml_connection.get("authFederationServiceAccount") is None + + +def test_update_connection_wif_service_account_without_mode(server: TSC.Server) -> None: + """TSC is permissive: a lone service account emits as-is and the server rejects it.""" + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + connection = TSC.ConnectionItem() + connection._id = "17376070-64d1-4d17-acb4-a56e4b5b1768" + connection.auth_federation_service_account = "sa@proj.iam.gserviceaccount.com" + response_xml = UPDATE_CONNECTION_WIF_XML.read_text() + + with requests_mock.mock() as m: + m.put(f"{server.workbooks.baseurl}/{workbook.id}/connections/{connection.id}", text=response_xml) + server.workbooks.update_connection(workbook, connection) + + request = m.request_history[0] + + xml_connection = fromstring(request.body).find(".//connection") + assert xml_connection is not None + assert xml_connection.get("workgroupAuthMode") is None + assert xml_connection.get("authFederationServiceAccount") == "sa@proj.iam.gserviceaccount.com" + + +def test_update_connection_wif_from_response(server: TSC.Server) -> None: + """If/when the server echoes WIF attrs, ConnectionItem.from_response surfaces them.""" + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + connection = TSC.ConnectionItem() + connection._id = "17376070-64d1-4d17-acb4-a56e4b5b1768" + response_xml = UPDATE_CONNECTION_WIF_XML.read_text() + + with requests_mock.mock() as m: + m.put(f"{server.workbooks.baseurl}/{workbook.id}/connections/{connection.id}", text=response_xml) + updated = server.workbooks.update_connection(workbook, connection) + + assert updated is not None + assert updated.workgroup_auth_mode == "workload-identity-federation" + assert updated.auth_federation_service_account is None + + +def test_update_connection_wif_from_response_absent(server: TSC.Server) -> None: + """Older servers (REST < 3.30) don't echo WIF attrs; from_response parses them as None.""" + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + connection = TSC.ConnectionItem() + connection._id = "17376070-64d1-4d17-acb4-a56e4b5b1768" + response_xml = ODATA_XML.read_text() + + with requests_mock.mock() as m: + m.put(f"{server.workbooks.baseurl}/{workbook.id}/connections/{connection.id}", text=response_xml) + updated = server.workbooks.update_connection(workbook, connection) + + assert updated is not None + assert updated.workgroup_auth_mode is None + assert updated.auth_federation_service_account is None + + +@pytest.mark.parametrize( + "workgroup_auth_mode, service_account, expected_mode, expected_sa", + [ + (TSC.ConnectionItem.WorkgroupAuthMode.WIF, None, "workload-identity-federation", None), + ( + TSC.ConnectionItem.WorkgroupAuthMode.WIF_IMPERSONATION, + "sa@proj.iam.gserviceaccount.com", + "workload-identity-federation-impersonation", + "sa@proj.iam.gserviceaccount.com", + ), + ], + ids=["direct", "impersonation"], +) +def test_update_workbook_connections_wif( + server: TSC.Server, + workgroup_auth_mode: str, + service_account: str | None, + expected_mode: str, + expected_sa: str | None, +) -> None: + """Bulk update_connections forwards WIF kwargs onto the single payload.""" + response_xml = UPDATE_CONNECTIONS_XML.read_text() + + with requests_mock.Mocker() as m: + workbook_id = "1a2b3c4d-5e6f-7a8b-9c0d-112233445566" + connection_luids = ["abc12345-def6-7890-gh12-ijklmnopqrst", "1234abcd-5678-efgh-ijkl-0987654321mn"] + + workbook = TSC.WorkbookItem(workbook_id) + workbook._id = workbook_id + server.version = "3.26" + m.put( + "http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks/1a2b3c4d-5e6f-7a8b-9c0d-112233445566/connections", + text=response_xml, + ) + + server.workbooks.update_connections( + workbook_item=workbook, + connection_luids=connection_luids, + workgroup_auth_mode=workgroup_auth_mode, + auth_federation_service_account=service_account, + ) + + request = m.request_history[0] + + xml_connection = fromstring(request.body).find(".//connection") + assert xml_connection is not None + assert xml_connection.get("workgroupAuthMode") == expected_mode + assert xml_connection.get("authFederationServiceAccount") == expected_sa + + def test_update_workbook_connections(server: TSC.Server) -> None: populate_xml = POPULATE_CONNECTIONS_XML.read_text() response_xml = UPDATE_CONNECTIONS_XML.read_text() From f2bad9b15b09749462cb37558fe8424b3e8cf4ff Mon Sep 17 00:00:00 2001 From: Sushma Sreedharan Date: Thu, 15 Oct 2026 13:44:55 -0700 Subject: [PATCH 6/6] docs: expand WIF guidance in CHANGELOG Address review on #1887: expand the Unreleased CHANGELOG entry to name the new nested value set (ConnectionItem.WorkgroupAuthMode.WIF / WIF_IMPERSONATION), note that the bulk workbooks.update_connections and datasources.update_connections endpoints now accept the matching keyword arguments, add flows.update_connection to the list of endpoints that forward WIF, state the usage rules (resend on every update, no service account with direct mode, auth_type unset or "oauth", server does not echo on GET today), and clarify that "3.30+" is a Tableau Server REST API version (26.3 server release) and that older servers silently ignore unknown connection attributes. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 237113adb..9f13b9605 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,18 +2,37 @@ ## Unreleased * Added Workload Identity Federation (WIF) support to the connection update - endpoints. `ConnectionItem` now has `workgroup_auth_mode` and - `auth_federation_service_account` string properties, which are emitted on - the `` payload by `workbooks.update_connection` / - `workbooks.update_connections` and `datasources.update_connection` / - `datasources.update_connections`. Set `workgroup_auth_mode` to one of the - new module-level constants `WORKGROUP_AUTH_MODE_WIF` - (`"workload-identity-federation"`, e.g. Snowflake) or - `WORKGROUP_AUTH_MODE_WIF_IMPERSONATION` - (`"workload-identity-federation-impersonation"`, e.g. Google BigQuery); - the service account email is required only for the impersonation mode. - Requires Tableau Server REST API 3.30+ and server-side WIF to be configured - and enabled on the site. + endpoints. `ConnectionItem` has two new string properties, + `workgroup_auth_mode` and `auth_federation_service_account`, that are + emitted on the `` payload by `workbooks.update_connection`, + `workbooks.update_connections`, `datasources.update_connection`, + `datasources.update_connections`, and `flows.update_connection`. The two + bulk endpoints also accept matching `workgroup_auth_mode` and + `auth_federation_service_account` keyword arguments and apply them to + every connection LUID in the request. Set `workgroup_auth_mode` to one of + the values on the new nested value set + `ConnectionItem.WorkgroupAuthMode`: + - `ConnectionItem.WorkgroupAuthMode.WIF` = + `"workload-identity-federation"` — direct WIF, e.g. Snowflake + - `ConnectionItem.WorkgroupAuthMode.WIF_IMPERSONATION` = + `"workload-identity-federation-impersonation"` — impersonation WIF, + e.g. Google BigQuery + Usage rules: + - Resend `workgroup_auth_mode` on every subsequent update of a WIF + connection; omitting it on a later update returns HTTP 400. + - Leave `auth_type` unset (or `"oauth"`) when sending a WIF mode; the + server derives the stored auth type from the mode. + - Set `auth_federation_service_account` only with `WIF_IMPERSONATION`. + The server rejects a service account with the direct `WIF` mode. TSC + does not validate this combination client-side. + - The server does not echo these attributes on GET responses today, so + `ConnectionItem.workgroup_auth_mode` and + `ConnectionItem.auth_federation_service_account` parsed from a response + will typically be `None` even when the connection is WIF-configured. + Requires Tableau Server REST API 3.30+ (Tableau Server release 26.3) with + WIF configured and enabled on the site. Older servers silently ignore + unknown connection attributes, so these fields are safe to leave unset on + pre-3.30 targets. * Bumped the urllib3 floor to 2.6.3 to pick up the fix for CVE-2026-21441 (GHSA-38jv-5279-wg99, 8.9 High): urllib3's streaming decompression safeguards were bypassed when HTTP redirects were followed. TSC's manual