Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions tableauserverclient/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -80,6 +83,9 @@
)

__all__ = [
"AuthFederationConnectorItem",
"AuthFederationItem",
"AuthFederationKeyItem",
"BackgroundJobItem",
"CollectionItem",
"ColumnItem",
Expand Down
8 changes: 8 additions & 0 deletions tableauserverclient/models/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -55,6 +60,9 @@
from tableauserverclient.models.extract_item import ExtractItem

__all__ = [
"AuthFederationConnectorItem",
"AuthFederationItem",
"AuthFederationKeyItem",
"CollectionItem",
"ColumnItem",
"ConnectionCredentials",
Expand Down
117 changes: 117 additions & 0 deletions tableauserverclient/models/auth_federation_item.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions tableauserverclient/server/endpoint/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -35,6 +36,7 @@

__all__ = [
"Auth",
"AuthFederation",
"CustomViews",
"DataAccelerationReport",
"DataAlerts",
Expand Down
Loading
Loading