diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f13b9605..6fcf193ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,52 @@ ## Unreleased +* Added Workload Identity Federation (WIF) admin REST API support + (`server.auth_federation`). Site admins can now manage the WIF config and + its signing keys and connectors from TSC. New endpoints: + - `server.auth_federation.get()` -> `AuthFederationItem` — read the + site's WIF admin config (enabled flag, issuer URL, active key id, + JWT lifetime, connectors). A never-enabled site returns a shell with + `enabled=False` rather than 404. + - `server.auth_federation.enable(token_lifetime_seconds=None)` -> + `AuthFederationItem` — idempotent PUT. Server auto-generates one active + signing key on first enable. + - `server.auth_federation.update(token_lifetime_seconds=None)` -> + `AuthFederationItem` — merge-patch; only `token_lifetime_seconds` is + mutable. `enabled` is never sent on PATCH; use `enable()` / `delete()` + to change WIF state. + - `server.auth_federation.delete()` — permanently deletes the WIF config + along with every signing key and connector on the site. A subsequent + `enable()` starts from a clean slate; prior connectors and keys are + not restored. + - `server.auth_federation.create_connector(connector)` -> + `AuthFederationConnectorItem`. `connector_class` must match a Tableau + connection type exactly (case-sensitive, e.g. `"bigquery"`, + `"snowflake"`). Fails with 409 if a connector with the same class + already exists on the site. + - `server.auth_federation.list_connectors()` -> `list[AuthFederationConnectorItem]` + - `server.auth_federation.get_connector(id)` and + `server.auth_federation.delete_connector(id)` accept either a connector + LUID string or an `AuthFederationConnectorItem` with `luid` set. All + id-taking methods reject `None`, empty strings, and non-UUID-shaped + targets client-side (raising `ValueError`) to prevent path injection. + - `server.auth_federation.create_key()` -> `AuthFederationKeyItem` — + generates a new (inactive) signing key; private key is never returned. + Fails with 400 if the site is already at the maximum number of keys. + - `server.auth_federation.activate_key(id)` / + `server.auth_federation.deactivate_key(id)` / + `server.auth_federation.delete_key(id)` accept either a `kid` string + or an `AuthFederationKeyItem` with `kid` set; `activate_key` is atomic + and deactivates any previously active key. `delete_key` fails with 400 + if the key is currently active — call `deactivate_key` first. The four + key-management methods are bound to `NO_ACCESS` server-side, which + rejects every OAuth-scoped session unconditionally (including every PAT + session, regardless of granted scopes); authenticate with a + username/password session. + Three new exports: `AuthFederationItem`, `AuthFederationConnectorItem`, + `AuthFederationKeyItem`. Requires Tableau Server REST API 3.30+ + (Tableau Server 2026.3) and the Site Administrator role on the current + site; non-admin callers receive HTTP 403. * 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 @@ -29,7 +75,7 @@ `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 + Requires Tableau Server REST API 3.30+ (Tableau Server 2026.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. diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index cb197d47c..819cfcfa6 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,6 +1,9 @@ from tableauserverclient.bin._version import get_versions from tableauserverclient.namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from tableauserverclient.models import ( + AuthFederationConnectorItem, + AuthFederationItem, + AuthFederationKeyItem, BackgroundJobItem, CollectionItem, ColumnItem, @@ -80,6 +83,9 @@ ) __all__ = [ + "AuthFederationConnectorItem", + "AuthFederationItem", + "AuthFederationKeyItem", "BackgroundJobItem", "CollectionItem", "ColumnItem", diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index aa28e0dbf..5eb79f8e8 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,3 +1,8 @@ +from tableauserverclient.models.auth_federation_item import ( + AuthFederationConnectorItem, + AuthFederationItem, + AuthFederationKeyItem, +) from tableauserverclient.models.collection_item import CollectionItem from tableauserverclient.models.column_item import ColumnItem from tableauserverclient.models.connection_credentials import ConnectionCredentials @@ -55,6 +60,9 @@ from tableauserverclient.models.extract_item import ExtractItem __all__ = [ + "AuthFederationConnectorItem", + "AuthFederationItem", + "AuthFederationKeyItem", "CollectionItem", "ColumnItem", "ConnectionCredentials", diff --git a/tableauserverclient/models/auth_federation_item.py b/tableauserverclient/models/auth_federation_item.py new file mode 100644 index 000000000..9fc898ef5 --- /dev/null +++ b/tableauserverclient/models/auth_federation_item.py @@ -0,0 +1,117 @@ +from defusedxml.ElementTree import fromstring + +from tableauserverclient.helpers.strings import nullable_str_to_bool, nullable_str_to_int + + +class AuthFederationConnectorItem: + def __init__(self) -> None: + self.luid: str | None = None + self.connector_class: str | None = None + self.audience: str | None = None + + def __str__(self) -> str: + return ( + f"{self.__class__.__qualname__}(luid={self.luid}, " + f"connector_class={self.connector_class}, audience={self.audience})" + ) + + def __repr__(self) -> str: + return f"<{str(self)}>" + + @classmethod + def from_element(cls, elem) -> "AuthFederationConnectorItem": + connector = cls() + connector.luid = elem.get("luid") + connector.connector_class = elem.get("connectorClass") + connector.audience = elem.get("audience") + return connector + + @classmethod + def from_response(cls, raw_xml: bytes, ns) -> "AuthFederationConnectorItem": + root = fromstring(raw_xml) + elem = root.find("t:authFederationConnector", namespaces=ns) + if elem is None: + raise ValueError("No authFederationConnector element found in the XML.") + return cls.from_element(elem) + + @classmethod + def list_from_response(cls, raw_xml: bytes, ns) -> list["AuthFederationConnectorItem"]: + root = fromstring(raw_xml) + list_elem = root.find("t:authFederationConnectors", namespaces=ns) + if list_elem is None: + raise ValueError("No authFederationConnectors element found in the XML.") + return [cls.from_element(c) for c in list_elem.findall("t:connector", namespaces=ns)] + + +class AuthFederationKeyItem: + def __init__(self) -> None: + self.kid: str | None = None + self.kty: str | None = None + self.alg: str | None = None + self.use: str | None = None + self.n: str | None = None + self.e: str | None = None + + def __str__(self) -> str: + return f"{self.__class__.__qualname__}(kid={self.kid}, kty={self.kty}, alg={self.alg}, use={self.use})" + + def __repr__(self) -> str: + return f"<{str(self)}>" + + @classmethod + def from_element(cls, elem) -> "AuthFederationKeyItem": + key = cls() + key.kid = elem.get("kid") + key.kty = elem.get("kty") + key.alg = elem.get("alg") + key.use = elem.get("use") + key.n = elem.get("n") + key.e = elem.get("e") + return key + + @classmethod + def from_response(cls, raw_xml: bytes, ns) -> "AuthFederationKeyItem": + root = fromstring(raw_xml) + elem = root.find("t:authFederationKey", namespaces=ns) + if elem is None: + raise ValueError("No authFederationKey element found in the XML.") + return cls.from_element(elem) + + +class AuthFederationItem: + def __init__(self) -> None: + self.enabled: bool = False + self.issuer_url: str | None = None + self.active_key_id: str | None = None + self.token_lifetime_seconds: int | None = None + self.connectors: list[AuthFederationConnectorItem] = [] + + def __str__(self) -> str: + return ( + f"{self.__class__.__qualname__}(enabled={self.enabled}, " + f"issuer_url={self.issuer_url}, active_key_id={self.active_key_id}, " + f"token_lifetime_seconds={self.token_lifetime_seconds}, " + f"connectors={len(self.connectors)})" + ) + + def __repr__(self) -> str: + return f"<{str(self)}>" + + @classmethod + def from_response(cls, raw_xml: bytes, ns) -> "AuthFederationItem": + root = fromstring(raw_xml) + elem = root.find("t:authFederationAdmin", namespaces=ns) + if elem is None: + raise ValueError("No authFederationAdmin element found in the XML.") + config = cls() + config.enabled = nullable_str_to_bool(elem.get("enabled")) or False + config.issuer_url = elem.get("issuerUrl") + config.active_key_id = elem.get("activeKeyId") + config.token_lifetime_seconds = nullable_str_to_int(elem.get("tokenLifetimeSeconds")) + connectors_elem = elem.find("t:connectors", namespaces=ns) + if connectors_elem is not None: + config.connectors = [ + AuthFederationConnectorItem.from_element(c) + for c in connectors_elem.findall("t:connector", namespaces=ns) + ] + return config diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 0a6eb8640..de2f7e8a1 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -1,4 +1,5 @@ from tableauserverclient.server.endpoint.auth_endpoint import Auth +from tableauserverclient.server.endpoint.auth_federation_endpoint import AuthFederation from tableauserverclient.server.endpoint.custom_views_endpoint import CustomViews from tableauserverclient.server.endpoint.data_acceleration_report_endpoint import DataAccelerationReport from tableauserverclient.server.endpoint.data_alert_endpoint import DataAlerts @@ -35,6 +36,7 @@ __all__ = [ "Auth", + "AuthFederation", "CustomViews", "DataAccelerationReport", "DataAlerts", diff --git a/tableauserverclient/server/endpoint/auth_federation_endpoint.py b/tableauserverclient/server/endpoint/auth_federation_endpoint.py new file mode 100644 index 000000000..6f0954d8e --- /dev/null +++ b/tableauserverclient/server/endpoint/auth_federation_endpoint.py @@ -0,0 +1,312 @@ +import uuid +from typing import Protocol, TYPE_CHECKING + +from tableauserverclient.models.auth_federation_item import ( + AuthFederationConnectorItem, + AuthFederationItem, + AuthFederationKeyItem, +) +from tableauserverclient.server.endpoint.endpoint import Endpoint, api +from tableauserverclient.server.request_factory import RequestFactory + +if TYPE_CHECKING: + from tableauserverclient.server.server import Server + + +class HasLuid(Protocol): + @property + def luid(self) -> str | None: ... + + +class HasKid(Protocol): + @property + def kid(self) -> str | None: ... + + +ConnectorIdentifier = str | HasLuid +KeyIdentifier = str | HasKid + + +def _coerce_uuid(target: object) -> str: + """Validate ``target`` is a UUID and return its canonical string form. + + Guards every id-taking method against path injection: without this, a + caller could pass ``"../../admin"`` and delete the entire WIF config via + the key-delete route. + """ + try: + return str(uuid.UUID(str(target))) + except (ValueError, AttributeError, TypeError) as e: + raise ValueError(f"Invalid UUID: {target!r}") from e + + +class AuthFederation(Endpoint): + """ + Workload Identity Federation (WIF) admin configuration for a Tableau Cloud + / Tableau Server site. Lets a site admin enable WIF, manage the signing + keys that mint JWTs for cloud-provider federation, and register the + connectors (BigQuery, Snowflake, ...) that each JWT is scoped to. Requires + REST API version 3.30 (Tableau Server 2026.3) or later. + + All methods require the caller to be a Site Administrator on the current + site; the server returns HTTP 403 (``ServerResponseError``) for non-admin + callers. Beyond ``get()`` and ``enable()``, every method also requires + WIF to be enabled on the site — call ``enable()`` first, otherwise the + server returns HTTP 404. + + The four key-management methods (``create_key``, ``activate_key``, + ``deactivate_key``, ``delete_key``) are bound to ``OAuthScopes.NO_ACCESS`` + server-side, which means every OAuth-scoped session is rejected + unconditionally — including every PAT session, regardless of granted + scopes. Authenticate with a username/password session to call them. The + config and connector methods accept the + ``tableau:connectivity_auth_federation:*`` OAuth scopes. + + ``delete()`` permanently removes the config, every signing key, and every + connector on the site; re-enabling with ``enable()`` starts from a clean + slate — prior connectors and keys are not restored, and any existing + datasource / workbook connections that reference WIF stay broken until + each connector is re-created. + """ + + def __init__(self, server: "Server") -> None: + self.parent_srv = server + + @property + def baseurl(self) -> str: + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/auth-federation/admin" + + # -- Admin config -------------------------------------------------------- + + @api(version="3.30") + def get(self) -> AuthFederationItem: + """ + Get the WIF admin config for the currently authenticated site. A site + that has never enabled WIF returns a default item with + ``enabled=False`` rather than raising a 404. + + Returns + ------- + AuthFederationItem + """ + response = self.get_request(self.baseurl) + return AuthFederationItem.from_response(response.content, self.parent_srv.namespace) + + @api(version="3.30") + def enable(self, token_lifetime_seconds: int | None = None) -> AuthFederationItem: + """ + Enable WIF for the currently authenticated site. Idempotent: calling + enable on an already-enabled site returns the current config. + + On first enable (not on subsequent idempotent calls), the server + auto-generates one active signing key and populates ``active_key_id`` + on the returned item, so a caller does not need to immediately + ``create_key`` + ``activate_key``. + + Parameters + ---------- + token_lifetime_seconds : int | None + Optional JWT lifetime in seconds. ``None`` leaves the server + default in place (or, on an already-enabled site, keeps whatever + lifetime is currently configured). + + Returns + ------- + AuthFederationItem + The current WIF admin config. + """ + req = RequestFactory.AuthFederation.enable_req(token_lifetime_seconds=token_lifetime_seconds) + response = self.put_request(self.baseurl, req) + return AuthFederationItem.from_response(response.content, self.parent_srv.namespace) + + @api(version="3.30") + def update(self, token_lifetime_seconds: int | None = None) -> AuthFederationItem: + """ + Merge-patch the mutable fields of the WIF admin config. Only + ``token_lifetime_seconds`` is mutable; ``enabled``, ``issuer_url``, + ``active_key_id``, and ``connectors`` are not — use ``enable()`` / + ``delete()`` to change WIF state and the connector / key endpoints + to manage those resources. Returns HTTP 404 if WIF has never been + enabled on the site. + + Parameters + ---------- + token_lifetime_seconds : int | None + New JWT lifetime in seconds. ``None`` sends an empty patch (no + fields changed) — rarely useful, but accepted by the server. + + Returns + ------- + AuthFederationItem + The updated WIF admin config. + """ + req = RequestFactory.AuthFederation.update_req(token_lifetime_seconds=token_lifetime_seconds) + response = self.patch_request(self.baseurl, req) + return AuthFederationItem.from_response(response.content, self.parent_srv.namespace) + + @api(version="3.30") + def delete(self) -> None: + """ + Delete the WIF admin config, every signing key, and every connector + on the site. Any datasource or workbook connections that reference a + WIF connector will no longer be able to authenticate until WIF is + re-enabled; the removed connectors and keys are not restored. + """ + self.delete_request(self.baseurl) + + # -- Connectors ---------------------------------------------------------- + + @api(version="3.30") + def create_connector(self, connector: AuthFederationConnectorItem) -> AuthFederationConnectorItem: + """ + Register a new WIF connector on the site. The server assigns the + connector's LUID. Fails with 409 if a connector with the same + ``connector_class`` already exists on the site. + + Parameters + ---------- + connector : AuthFederationConnectorItem + ``connector_class`` and ``audience`` are required. + ``connector_class`` must equal the Tableau connection class + exactly — what ``ConnectionItem.connection_type`` reports for the + connector (e.g. ``"bigquery"``, ``"snowflake"``). Class names + are case-sensitive; use the exact lowercase class name of the + connector. + + Returns + ------- + AuthFederationConnectorItem + The created connector, with the server-assigned ``luid`` populated. + """ + url = f"{self.baseurl}/connectors" + req = RequestFactory.AuthFederation.create_connector_req(connector) + response = self.post_request(url, req) + return AuthFederationConnectorItem.from_response(response.content, self.parent_srv.namespace) + + @api(version="3.30") + def list_connectors(self) -> list[AuthFederationConnectorItem]: + """ + List every WIF connector registered on the site. Returns an empty list + if WIF is enabled but has no connectors. If WIF is not enabled on the + site, the server returns HTTP 404 (surfaced as ``ServerResponseError``) + — call ``enable()`` first. + + Returns + ------- + list[AuthFederationConnectorItem] + """ + url = f"{self.baseurl}/connectors" + response = self.get_request(url) + return AuthFederationConnectorItem.list_from_response(response.content, self.parent_srv.namespace) + + @api(version="3.30") + def get_connector(self, connector: ConnectorIdentifier) -> AuthFederationConnectorItem: + """ + Get a single WIF connector by LUID. + + Parameters + ---------- + connector : str | HasLuid + The connector LUID, or an ``AuthFederationConnectorItem`` whose + ``luid`` is set. + + Returns + ------- + AuthFederationConnectorItem + """ + target = _coerce_uuid(getattr(connector, "luid", connector)) + url = f"{self.baseurl}/connectors/{target}" + response = self.get_request(url) + return AuthFederationConnectorItem.from_response(response.content, self.parent_srv.namespace) + + @api(version="3.30") + def delete_connector(self, connector: ConnectorIdentifier) -> None: + """ + Delete a WIF connector by LUID. Idempotent on an unknown but + well-formed LUID (the server returns 204 even if no such connector + exists) — but only when WIF is enabled on the site; otherwise the + server returns a WIF-not-enabled error. Client-side UUID validation + rejects a malformed id before any HTTP call. + + Existing site connections that reference the removed connector are + orphaned, not cascade-deleted. + + Parameters + ---------- + connector : str | HasLuid + The connector LUID, or an ``AuthFederationConnectorItem`` whose + ``luid`` is set. + """ + target = _coerce_uuid(getattr(connector, "luid", connector)) + url = f"{self.baseurl}/connectors/{target}" + self.delete_request(url) + + # -- Keys ---------------------------------------------------------------- + + @api(version="3.30") + def create_key(self) -> AuthFederationKeyItem: + """ + Generate a new WIF signing key. Keys are created in the inactive state; + use ``activate_key`` to promote one to the site's active signing key. + Fails with 400 (``ServerResponseError``) if the site is already at the + maximum number of keys. + + Returns + ------- + AuthFederationKeyItem + The created key's JWK metadata (``kid``, ``kty``, ``alg``, ``use``, + ``n``, ``e``). The private key is never exposed by the REST API. + """ + url = f"{self.baseurl}/keys" + req = RequestFactory.Empty.empty_req() + response = self.post_request(url, req) + return AuthFederationKeyItem.from_response(response.content, self.parent_srv.namespace) + + @api(version="3.30") + def activate_key(self, key: KeyIdentifier) -> None: + """ + Promote the given key to the site's active signing key. Atomic: + deactivates whichever key was previously active in the same operation. + + Parameters + ---------- + key : str | HasKid + The key ID (``kid``), or an ``AuthFederationKeyItem`` whose ``kid`` + is set. + """ + target = _coerce_uuid(getattr(key, "kid", key)) + url = f"{self.baseurl}/keys/{target}/activate" + self.put_request(url) + + @api(version="3.30") + def deactivate_key(self, key: KeyIdentifier) -> None: + """ + Deactivate the given key. A deactivated key stays on the site (and in + the public JWKS) so that already-issued JWTs can still be verified + during their validity window, but no new JWTs will be signed with it. + + Parameters + ---------- + key : str | HasKid + The key ID (``kid``), or an ``AuthFederationKeyItem`` whose ``kid`` + is set. + """ + target = _coerce_uuid(getattr(key, "kid", key)) + url = f"{self.baseurl}/keys/{target}/deactivate" + self.put_request(url) + + @api(version="3.30") + def delete_key(self, key: KeyIdentifier) -> None: + """ + Delete a WIF signing key. Fails with 400 if the key is active; + deactivate it first. + + Parameters + ---------- + key : str | HasKid + The key ID (``kid``), or an ``AuthFederationKeyItem`` whose ``kid`` + is set. + """ + target = _coerce_uuid(getattr(key, "kid", key)) + url = f"{self.baseurl}/keys/{target}" + self.delete_request(url) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index ca097fbc9..2c781a572 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -104,6 +104,38 @@ def switch_req(self, site_content_url): return ET.tostring(xml_request) +class AuthFederationRequest: + # The WIF admin controller binds the request body directly as + # ``AuthFederationConfigType`` / ``AuthFederationConnectorType`` — a bare + # element, not the usual ```` envelope. These methods therefore + # skip ``_tsrequest_wrapped`` and emit the bare root themselves. + + def enable_req(self, token_lifetime_seconds: int | None = None) -> bytes: + elem = ET.Element("authFederationAdmin") + elem.attrib["enabled"] = "true" + if token_lifetime_seconds is not None: + elem.attrib["tokenLifetimeSeconds"] = str(token_lifetime_seconds) + return ET.tostring(elem) + + def update_req(self, token_lifetime_seconds: int | None = None) -> bytes: + # ``enabled`` must never be sent on PATCH — the server rejects a merge + # patch with ``enabled`` present. Enable/disable goes through PUT/DELETE. + elem = ET.Element("authFederationAdmin") + if token_lifetime_seconds is not None: + elem.attrib["tokenLifetimeSeconds"] = str(token_lifetime_seconds) + return ET.tostring(elem) + + def create_connector_req(self, connector: "AuthFederationConnectorItem") -> bytes: + if connector.connector_class is None: + raise ValueError(f"AuthFederationConnectorItem missing connector_class: {connector}") + if connector.audience is None: + raise ValueError(f"AuthFederationConnectorItem missing audience: {connector}") + elem = ET.Element("authFederationConnector") + elem.attrib["connectorClass"] = connector.connector_class + elem.attrib["audience"] = connector.audience + return ET.tostring(elem) + + class ColumnRequest: def update_req(self, column_item): xml_request = ET.Element("tsRequest") @@ -1754,6 +1786,7 @@ def update_site_extensions(self, xml_request: ET.Element, extensions_site_settin class RequestFactory: Auth = AuthRequest() + AuthFederation = AuthFederationRequest() Connection = Connection() Column = ColumnRequest() CustomView = CustomViewRequest() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 9e5797dc4..853bdea68 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -19,6 +19,7 @@ Datasources, Projects, Auth, + AuthFederation, Schedules, ServerInfo, Tasks, @@ -232,6 +233,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.tags = Tags(self) self.virtual_connections = VirtualConnections(self) self.oidc = OIDC(self) + self.auth_federation = AuthFederation(self) self.extensions = Extensions(self) self._http_options = dict() # must set this before making a server call diff --git a/test/assets/auth_federation_connector_create.xml b/test/assets/auth_federation_connector_create.xml new file mode 100644 index 000000000..7c7236952 --- /dev/null +++ b/test/assets/auth_federation_connector_create.xml @@ -0,0 +1,10 @@ + + + + diff --git a/test/assets/auth_federation_connector_get.xml b/test/assets/auth_federation_connector_get.xml new file mode 100644 index 000000000..7c7236952 --- /dev/null +++ b/test/assets/auth_federation_connector_get.xml @@ -0,0 +1,10 @@ + + + + diff --git a/test/assets/auth_federation_connectors_list.xml b/test/assets/auth_federation_connectors_list.xml new file mode 100644 index 000000000..fa3eb4f5e --- /dev/null +++ b/test/assets/auth_federation_connectors_list.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/test/assets/auth_federation_connectors_list_empty.xml b/test/assets/auth_federation_connectors_list_empty.xml new file mode 100644 index 000000000..bf1ce9545 --- /dev/null +++ b/test/assets/auth_federation_connectors_list_empty.xml @@ -0,0 +1,7 @@ + + + + diff --git a/test/assets/auth_federation_enable.xml b/test/assets/auth_federation_enable.xml new file mode 100644 index 000000000..9c9e0e8ae --- /dev/null +++ b/test/assets/auth_federation_enable.xml @@ -0,0 +1,11 @@ + + + + diff --git a/test/assets/auth_federation_get.xml b/test/assets/auth_federation_get.xml new file mode 100644 index 000000000..c9714e6d8 --- /dev/null +++ b/test/assets/auth_federation_get.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/test/assets/auth_federation_key_create.xml b/test/assets/auth_federation_key_create.xml new file mode 100644 index 000000000..c16f79c6f --- /dev/null +++ b/test/assets/auth_federation_key_create.xml @@ -0,0 +1,13 @@ + + + + diff --git a/test/assets/auth_federation_update.xml b/test/assets/auth_federation_update.xml new file mode 100644 index 000000000..5efbb868a --- /dev/null +++ b/test/assets/auth_federation_update.xml @@ -0,0 +1,11 @@ + + + + diff --git a/test/test_auth_federation.py b/test/test_auth_federation.py new file mode 100644 index 000000000..ac2b770de --- /dev/null +++ b/test/test_auth_federation.py @@ -0,0 +1,371 @@ +from pathlib import Path + +import pytest +import requests_mock +from defusedxml.ElementTree import fromstring + +import tableauserverclient as TSC +from tableauserverclient.server.exceptions import EndpointUnavailableError + +assets = Path(__file__).parent / "assets" +GET = assets / "auth_federation_get.xml" +ENABLE = assets / "auth_federation_enable.xml" +UPDATE = assets / "auth_federation_update.xml" +CONNECTOR_CREATE = assets / "auth_federation_connector_create.xml" +CONNECTOR_GET = assets / "auth_federation_connector_get.xml" +CONNECTORS_LIST = assets / "auth_federation_connectors_list.xml" +CONNECTORS_LIST_EMPTY = assets / "auth_federation_connectors_list_empty.xml" +KEY_CREATE = assets / "auth_federation_key_create.xml" + +NS = {"t": "http://tableau.com/api"} +CONNECTOR_LUID = "d3a8e5b2-7c01-4e13-9c82-6f45a9b4e8d1" +KEY_KID = "a4b9c2d7-ff81-45e8-9c3e-6b19d2a5c0ee" + + +@pytest.fixture(scope="function") +def server() -> TSC.Server: + server = TSC.Server("http://test", False) + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + server.version = "3.30" + return server + + +def test_auth_federation_get(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(server.auth_federation.baseurl, text=GET.read_text()) + config = server.auth_federation.get() + + assert config.enabled is True + assert config.issuer_url == "https://10ax.online.tableau.com/sites/mySite/auth-federation" + assert config.active_key_id == "a4b9c2d7-ff81-45e8-9c3e-6b19d2a5c0ee" + assert config.token_lifetime_seconds == 3600 + assert len(config.connectors) == 2 + assert {c.connector_class for c in config.connectors} == {"bigquery", "snowflake"} + bigquery = next(c for c in config.connectors if c.connector_class == "bigquery") + assert bigquery.luid == CONNECTOR_LUID + assert bigquery.audience.startswith("//iam.googleapis.com/") + + +def test_auth_federation_get_defaults_when_never_enabled(server: TSC.Server) -> None: + # Server GET on a never-enabled site still returns ``issuerUrl`` and the + # default ``tokenLifetimeSeconds``; only ``activeKeyId`` and the + # ```` wrapper are absent. + default_body = ( + '' + '' + "" + ) + with requests_mock.mock() as m: + m.get(server.auth_federation.baseurl, text=default_body) + config = server.auth_federation.get() + + assert config.enabled is False + assert config.issuer_url == "https://10ax.online.tableau.com/sites/mySite/auth-federation" + assert config.active_key_id is None + assert config.token_lifetime_seconds == 3600 + assert config.connectors == [] + + +def test_auth_federation_enable(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.put(server.auth_federation.baseurl, text=ENABLE.read_text()) + config_out = server.auth_federation.enable(token_lifetime_seconds=3600) + body = m.request_history[0].text + + # WIF admin controller binds the body as a bare AuthFederationConfigType; + # the request MUST NOT be wrapped in . + elem = fromstring(body) + assert elem.tag == "authFederationAdmin" + assert elem.get("enabled") == "true" + assert elem.get("tokenLifetimeSeconds") == "3600" + assert elem.find("connectors") is None + + assert config_out.enabled is True + assert config_out.active_key_id == "a4b9c2d7-ff81-45e8-9c3e-6b19d2a5c0ee" + assert config_out.token_lifetime_seconds == 3600 + + +def test_auth_federation_enable_without_token_lifetime(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.put(server.auth_federation.baseurl, text=ENABLE.read_text()) + server.auth_federation.enable() + body = m.request_history[0].text + + elem = fromstring(body) + assert elem.tag == "authFederationAdmin" + assert elem.get("enabled") == "true" + assert "tokenLifetimeSeconds" not in elem.attrib + + +def test_auth_federation_update(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.patch(server.auth_federation.baseurl, text=UPDATE.read_text()) + config_out = server.auth_federation.update(token_lifetime_seconds=7200) + body = m.request_history[0].text + + elem = fromstring(body) + assert elem.tag == "authFederationAdmin" + assert elem.get("tokenLifetimeSeconds") == "7200" + # ``enabled`` must never be sent on PATCH — the server rejects a merge + # patch that includes it. + assert "enabled" not in elem.attrib + # Read-only fields must not be sent either. + assert "issuerUrl" not in elem.attrib + assert "activeKeyId" not in elem.attrib + + assert config_out.token_lifetime_seconds == 7200 + + +def test_auth_federation_update_empty_patch(server: TSC.Server) -> None: + # This exercises the client wire shape only. Whether the server binds an + # empty ```` as a non-null ``AuthFederationConfigType`` + # with every field null (a no-op merge-patch) or as a null body (400) must + # be covered by a server-side integration test — not here. + with requests_mock.mock() as m: + m.patch(server.auth_federation.baseurl, text=UPDATE.read_text()) + server.auth_federation.update() + body = m.request_history[0].text + + elem = fromstring(body) + assert elem.tag == "authFederationAdmin" + assert "enabled" not in elem.attrib + assert "tokenLifetimeSeconds" not in elem.attrib + + +def test_auth_federation_delete(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.delete(server.auth_federation.baseurl, status_code=204) + server.auth_federation.delete() + + assert m.called_once + + +def test_auth_federation_create_connector(server: TSC.Server) -> None: + connector_in = TSC.AuthFederationConnectorItem() + connector_in.connector_class = "bigquery" + connector_in.audience = ( + "//iam.googleapis.com/projects/123456789/locations/global/" + "workloadIdentityPools/tableau-pool/providers/tableau-provider" + ) + + with requests_mock.mock() as m: + m.post(f"{server.auth_federation.baseurl}/connectors", text=CONNECTOR_CREATE.read_text()) + connector_out = server.auth_federation.create_connector(connector_in) + body = m.request_history[0].text + + elem = fromstring(body) + assert elem.tag == "authFederationConnector" + assert elem.get("connectorClass") == "bigquery" + assert elem.get("audience") == connector_in.audience + assert "luid" not in elem.attrib + + assert connector_out.luid == CONNECTOR_LUID + assert connector_out.connector_class == "bigquery" + + +def test_auth_federation_create_connector_requires_class(server: TSC.Server) -> None: + connector_in = TSC.AuthFederationConnectorItem() + connector_in.audience = "snowflakecomputing.com" + + with pytest.raises(ValueError, match="connector_class"): + server.auth_federation.create_connector(connector_in) + + +def test_auth_federation_create_connector_requires_audience(server: TSC.Server) -> None: + connector_in = TSC.AuthFederationConnectorItem() + connector_in.connector_class = "snowflake" + + with pytest.raises(ValueError, match="audience"): + server.auth_federation.create_connector(connector_in) + + +def test_auth_federation_list_connectors(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(f"{server.auth_federation.baseurl}/connectors", text=CONNECTORS_LIST.read_text()) + connectors = server.auth_federation.list_connectors() + + assert len(connectors) == 2 + classes = {c.connector_class for c in connectors} + assert classes == {"bigquery", "snowflake"} + + +def test_auth_federation_list_connectors_empty(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(f"{server.auth_federation.baseurl}/connectors", text=CONNECTORS_LIST_EMPTY.read_text()) + connectors = server.auth_federation.list_connectors() + + assert connectors == [] + + +def test_auth_federation_get_connector_by_luid(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get( + f"{server.auth_federation.baseurl}/connectors/{CONNECTOR_LUID}", + text=CONNECTOR_GET.read_text(), + ) + connector = server.auth_federation.get_connector(CONNECTOR_LUID) + + assert connector.luid == CONNECTOR_LUID + assert connector.connector_class == "bigquery" + + +def test_auth_federation_get_connector_by_item(server: TSC.Server) -> None: + stub = TSC.AuthFederationConnectorItem() + stub.luid = CONNECTOR_LUID + + with requests_mock.mock() as m: + m.get( + f"{server.auth_federation.baseurl}/connectors/{CONNECTOR_LUID}", + text=CONNECTOR_GET.read_text(), + ) + connector = server.auth_federation.get_connector(stub) + + assert connector.luid == CONNECTOR_LUID + + +def test_auth_federation_delete_connector_by_luid(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.delete(f"{server.auth_federation.baseurl}/connectors/{CONNECTOR_LUID}", status_code=204) + server.auth_federation.delete_connector(CONNECTOR_LUID) + + assert m.called_once + + +def test_auth_federation_delete_connector_by_item(server: TSC.Server) -> None: + stub = TSC.AuthFederationConnectorItem() + stub.luid = CONNECTOR_LUID + + with requests_mock.mock() as m: + m.delete(f"{server.auth_federation.baseurl}/connectors/{CONNECTOR_LUID}", status_code=204) + server.auth_federation.delete_connector(stub) + + assert m.called_once + + +def test_auth_federation_create_key(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.post(f"{server.auth_federation.baseurl}/keys", text=KEY_CREATE.read_text()) + key = server.auth_federation.create_key() + body = m.request_history[0].text + + # POST /keys takes no body besides the empty tsRequest envelope. + envelope = fromstring(body) + assert envelope.tag == "tsRequest" + assert len(envelope) == 0 + + assert key.kid == KEY_KID + assert key.kty == "RSA" + assert key.alg == "RS256" + assert key.use == "sig" + assert key.e == "AQAB" + assert key.n is not None and len(key.n) > 0 + + +def test_auth_federation_activate_key(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.put(f"{server.auth_federation.baseurl}/keys/{KEY_KID}/activate", status_code=204) + server.auth_federation.activate_key(KEY_KID) + + assert m.called_once + + +def test_auth_federation_activate_key_by_item(server: TSC.Server) -> None: + stub = TSC.AuthFederationKeyItem() + stub.kid = KEY_KID + + with requests_mock.mock() as m: + m.put(f"{server.auth_federation.baseurl}/keys/{KEY_KID}/activate", status_code=204) + server.auth_federation.activate_key(stub) + + assert m.called_once + + +def test_auth_federation_deactivate_key(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.put(f"{server.auth_federation.baseurl}/keys/{KEY_KID}/deactivate", status_code=204) + server.auth_federation.deactivate_key(KEY_KID) + + assert m.called_once + + +def test_auth_federation_deactivate_key_by_item(server: TSC.Server) -> None: + stub = TSC.AuthFederationKeyItem() + stub.kid = KEY_KID + + with requests_mock.mock() as m: + m.put(f"{server.auth_federation.baseurl}/keys/{KEY_KID}/deactivate", status_code=204) + server.auth_federation.deactivate_key(stub) + + assert m.called_once + + +def test_auth_federation_delete_key(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.delete(f"{server.auth_federation.baseurl}/keys/{KEY_KID}", status_code=204) + server.auth_federation.delete_key(KEY_KID) + + assert m.called_once + + +def test_auth_federation_delete_key_by_item(server: TSC.Server) -> None: + stub = TSC.AuthFederationKeyItem() + stub.kid = KEY_KID + + with requests_mock.mock() as m: + m.delete(f"{server.auth_federation.baseurl}/keys/{KEY_KID}", status_code=204) + server.auth_federation.delete_key(stub) + + assert m.called_once + + +# -- UUID path-injection guard ------------------------------------------------- + +_UUID_METHODS = [ + "get_connector", + "delete_connector", + "activate_key", + "deactivate_key", + "delete_key", +] + + +@pytest.mark.parametrize("method_name", _UUID_METHODS) +@pytest.mark.parametrize("bad_target", ["../../admin", "not-a-uuid", "a4b9c2d7", "", "d3a8e5b2-7c01-4e13-9c82"]) +def test_auth_federation_rejects_non_uuid_target(server: TSC.Server, method_name: str, bad_target: str) -> None: + method = getattr(server.auth_federation, method_name) + with requests_mock.mock() as m: + m.register_uri(requests_mock.ANY, requests_mock.ANY) + with pytest.raises(ValueError, match="Invalid UUID"): + method(bad_target) + assert not m.called # must reject before any HTTP call + + +# -- Version gate -------------------------------------------------------------- + +_V3_30_METHODS = [ + ("get", (), {}), + ("enable", (), {"token_lifetime_seconds": 3600}), + ("update", (), {"token_lifetime_seconds": 7200}), + ("delete", (), {}), + ("create_connector", (TSC.AuthFederationConnectorItem(),), {}), + ("list_connectors", (), {}), + ("get_connector", (CONNECTOR_LUID,), {}), + ("delete_connector", (CONNECTOR_LUID,), {}), + ("create_key", (), {}), + ("activate_key", (KEY_KID,), {}), + ("deactivate_key", (KEY_KID,), {}), + ("delete_key", (KEY_KID,), {}), +] + + +@pytest.mark.parametrize("method_name,args,kwargs", _V3_30_METHODS, ids=[m[0] for m in _V3_30_METHODS]) +def test_auth_federation_requires_server_version_3_30( + server: TSC.Server, method_name: str, args: tuple, kwargs: dict +) -> None: + server.version = "3.29" + method = getattr(server.auth_federation, method_name) + with pytest.raises(EndpointUnavailableError): + method(*args, **kwargs)