diff --git a/CHANGELOG.md b/CHANGELOG.md index db9780ee9..9f13b9605 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,38 @@ ## Unreleased +* Added Workload Identity Federation (WIF) support to the connection update + 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 diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index fa4b8cf9f..29945e569 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -50,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 @@ -66,6 +108,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 +150,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 +196,8 @@ 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/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 3301b3b89..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 @@ -1226,6 +1242,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..e2111e68f --- /dev/null +++ b/test/assets/datasource_update_connection_wif.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/assets/workbook_update_connection_wif.xml b/test/assets/workbook_update_connection_wif.xml new file mode 100644 index 000000000..289a62619 --- /dev/null +++ b/test/assets/workbook_update_connection_wif.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index 95332c6db..824142c11 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,198 @@ def test_update_connection(server) -> None: assert "foo" == new_connection.username +@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() + + 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 = 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") == expected_mode + assert xml_connection.get("authFederationServiceAccount") == expected_sa + + +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() + + 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" + 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) + + 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") == "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 7f4d80041..c3188c4c8 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,204 @@ def test_odata_connection(server: TSC.Server) -> None: assert xml_connection.get("serverAddress") == url +@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 = 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: + 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") == expected_mode + assert xml_connection.get("authFederationServiceAccount") == expected_sa + + +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.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() + + 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") == "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()