Skip to content

Commit 83b8c14

Browse files
@W-24145098 - Add Workload Identity Federation (WIF) admin REST API (#1894)
* @W-24145098 - Add Workload Identity Federation (WIF) admin REST API Expose the Tableau Server WIF admin surface (REST API 3.30+ / Tableau Server release 2026.3) on the TSC server object as `server.auth_federation`. Site admins can now manage the whole WIF lifecycle from the client, complementing the per-connection WIF attributes delivered in PR #1887. New endpoint methods on `server.auth_federation`: Admin config (`/sites/{siteLuid}/auth-federation/admin`) - get() -> AuthFederationItem - enable(config) -> AuthFederationItem (idempotent PUT; requires enabled=True) - update(config) -> AuthFederationItem (merge-patch; only `token_lifetime_seconds` is mutable - `issuer_url`, `active_key_id`, and `connectors` are read-only and never sent on the wire) - delete() (deletes config + keys + connectors) Connectors (`.../admin/connectors`) - create_connector(connector) -> AuthFederationConnectorItem - list_connectors() -> list[AuthFederationConnectorItem] - get_connector(id) (accepts luid string or item) - delete_connector(id) (accepts luid string or item; idempotent) Keys (`.../admin/keys`) - create_key() -> AuthFederationKeyItem (inactive by default; private key is never returned) - activate_key(id) (atomic; deactivates whatever key was active) - deactivate_key(id) - delete_key(id) (400 if the key is still active) Three new public models exported from both `tableauserverclient.models` and the TSC top-level: `AuthFederationItem`, `AuthFederationConnectorItem`, `AuthFederationKeyItem`. All methods version-gated by @api(version="3.30"). Includes 22 unit tests under `test/test_auth_federation.py` covering the happy path for every method, request-body shape (via defusedxml parsing of `request_history[0].text`) for PUT/PATCH/POST verbs, the read-only-field hygiene of `update()`, the enabled=True guard on `enable()`, the required-field guards on `create_connector`, the list-connectors empty case, both the "pass a str" and "pass an item" forms of every id-taking method, and the EndpointUnavailableError raised when running against a server older than 3.30. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Document that AuthFederation.update() drops `enabled` Server-side AuthFederationAdminAppService.updateConfig rejects any non-null `enabled` on PATCH with HTTP 400 and tells callers to use enable() / delete() to change state. The client's update_req already only sends `tokenLifetimeSeconds`, but the docstring listed only issuer_url, active_key_id, and connectors as "silently ignored", leaving `enabled` as a hidden footgun. Add it to the list and point callers at the correct methods. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Align AuthFederation docstrings with monolith server behaviour Three docstring claims didn't match what the server actually does per AuthFederationService.java and RestApiAuthFederationKeysAppService.java: * enable(): did not mention that AuthFederationService.enableWif auto-generates one active signing key on first enable (AuthFederationService.java:160-164). A caller doesn't need to immediately create_key + activate_key — the returned item's active_key_id is already populated. * list_connectors(): claimed empty-list on "WIF not enabled". The server's requireWifEnabled (AuthFederationService.java:226-233) throws WIF_NOT_ENABLED_FOR_SITE / FAILED_PRECONDITION, which the client surfaces as ServerResponseError. Only an empty configured site returns []. * create_key(): claimed 409 for the max-keys-exceeded case. The server throws BadRequestRestException (HTTP 400), not 409 (RestApiAuthFederationKeysAppService.java:44-48). Docstring-only; no code or test changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Correct list_connectors() docstring: server returns HTTP 404, not 412 RestApiAuthFederationAdminAppService.listConnectors intercepts TableauStatusCode.FAILED_PRECONDITION from the service-layer requireWifEnabled check and re-throws configNotFound (HTTP 404) with AUTH_FEDERATION_NOT_FOUND -- see RestApiAuthFederationAdminAppService.java:157-162. The service-layer behavior cited in the previous commit does not reach the wire; callers see 404. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address review feedback on AuthFederation docs and tests - Reword PAT/OAuth-scope guidance: the four key methods are bound to OAuthScopes.NO_ACCESS server-side, so every OAuth-scoped session (including every PAT) is rejected unconditionally. Authenticate with a username/password session instead. Reflected in CHANGELOG and the class docstring. - Drop the misleading "Snowflake" case-sensitivity counter-example in create_connector; just state that class names are case-sensitive and must be the exact lowercase name. - Note in CHANGELOG that delete_key fails with 400 on an active key; deactivate first. - Replace vague "server rejects the request" in the class docstring with the specific HTTP 404 the server returns when WIF has never been enabled. - Make the _coerce_uuid wording in CHANGELOG explicit: None, empty strings, and non-UUID-shaped targets are rejected client-side. - Add a comment in test_auth_federation_update_empty_patch noting that server-side binding of an empty <authFederationAdmin/> must be covered by an integration test, not this unit test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Correct 403/404 and PAT semantics; add e2e round-trip test Review follow-up on PR #1894. - 403/404 docstring and CHANGELOG rewritten per server behavior: authorizationCheck() runs requireWifFeatureEnabled first, so every method (incl. get()) returns 403 when the per-site WIF feature flag is off — even for a site admin. 404 holds only for update() and the four connector methods; delete() on a never-enabled site is a no-op; create_key() has no enable() gate and leaves an orphan key against the per-site key limit; activate/deactivate/delete_key 404 only on unknown kid. - PAT wording corrected: NO_ACCESS rejects only OAuth-scope-restricted sessions (e.g. connected-app JWT) with 401. PAT and password sessions do not carry scopes and are accepted — this is what the repo's own e2e harness relies on. - delete_key rotation guidance: deactivating the active key with no replacement leaves the site unable to mint tokens. Safe order is create_key() -> activate_key(new) -> delete_key(old). - New test_e2e/test_auth_federation.py: PAT-signed-in round trip across every method. Primarily a wire-shape regression test for the three bare-body routes (PUT/PATCH /admin, POST /admin/connectors) that mocked unit tests cannot catch. Guards against clobbering a live WIF config and tears down to a clean site on exit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Tighten AuthFederation tests + require update() lifetime arg Three review nits from Wayne Xie's second pass on PR #1894: - Realism in the "never-enabled" GET fixture: use the real server defaults — token lifetime 7200 (AuthFederationService.java:86) and the actual issuer URL shape %s/api/%s/sites/<luid>/auth-federation (:748-766), not the fabricated /sites/mySite/... URL and 3600 lifetime. - UUID-guard matrix: add None to the bad-target list and parametrize over {raw, item} so the item-wrapped None path (connector.luid=None / key.kid=None) is also covered. Widens bad_target to object. - update() token_lifetime_seconds is now required: there is no other mutable field on the config, so a no-arg call is not a meaningful operation. Tightens the matching request_factory signature and drops the test that exercised the empty-patch wire shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address Copilot review comments on WIF admin PR - E2E teardown: let `ServerResponseError` from `auth_federation.delete()` propagate instead of swallowing it. A failed teardown left WIF enabled on the site, so the next run would hit the fixture's `enabled=True` guard and silently skip — the destructive paths stopped being exercised without anyone noticing. - Key-rotation guidance (endpoint docstring, CHANGELOG, and docs): the previous `create_key() -> activate_key(new) -> delete_key(old)` sequence omitted that the old key must stay in the JWKS until every JWT it signed has expired. Deleting it immediately removes it from the key set, and verifiers that fetch JWKS afterwards reject the still-valid tokens signed with it. New guidance keeps the old key inactive (but published) for up to `token_lifetime_seconds` before deleting. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Remove rationale comment from WIF e2e teardown The behavior is self-evident from a bare `delete()` with no try/except; the rationale belongs in the PR / commit history, not the test body. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Rework key-lifecycle docstrings per PR #1894 review - deactivate_key: absorb the "deactivate on the active key with no replacement leaves the site unable to mint" warning, which belongs on deactivate_key itself (not delete_key). - delete_key: drop the create_key / activate_key chain from the rotation guidance. Reframe the docstring around what distinguishes delete from deactivate: both stop new signing with the key, but delete also pulls it from the JWKS, so still-unexpired tokens signed with it stop verifying. Rotation guidance now reduces to "keep the old key deactivated-but-published until its tokens expire, then delete". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3219c9d commit 83b8c14

18 files changed

Lines changed: 1164 additions & 1 deletion

‎CHANGELOG.md‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,59 @@
11

22
## Unreleased
33

4+
* Added Workload Identity Federation (WIF) admin REST API support
5+
(`server.auth_federation`). Site admins can now manage the WIF config and
6+
its signing keys and connectors from TSC. New endpoints:
7+
- `server.auth_federation.get()` -> `AuthFederationItem` — read the
8+
site's WIF admin config (enabled flag, issuer URL, active key id,
9+
JWT lifetime, connectors). A never-enabled site returns a shell with
10+
`enabled=False` rather than 404.
11+
- `server.auth_federation.enable(token_lifetime_seconds=None)` ->
12+
`AuthFederationItem` — idempotent PUT. Server auto-generates one active
13+
signing key on first enable.
14+
- `server.auth_federation.update(token_lifetime_seconds)` ->
15+
`AuthFederationItem` — merge-patch; only `token_lifetime_seconds` is
16+
mutable (required), so `update()` always changes it. `enabled` is never
17+
sent on PATCH; use `enable()` / `delete()` to change WIF state.
18+
- `server.auth_federation.delete()` — permanently deletes the WIF config
19+
along with every signing key and connector on the site. A subsequent
20+
`enable()` starts from a clean slate; prior connectors and keys are
21+
not restored.
22+
- `server.auth_federation.create_connector(connector)` ->
23+
`AuthFederationConnectorItem`. `connector_class` must match a Tableau
24+
connection type exactly (case-sensitive, e.g. `"bigquery"`,
25+
`"snowflake"`). Fails with 409 if a connector with the same class
26+
already exists on the site.
27+
- `server.auth_federation.list_connectors()` -> `list[AuthFederationConnectorItem]`
28+
- `server.auth_federation.get_connector(id)` and
29+
`server.auth_federation.delete_connector(id)` accept either a connector
30+
LUID string or an `AuthFederationConnectorItem` with `luid` set. All
31+
id-taking methods reject `None`, empty strings, and non-UUID-shaped
32+
targets client-side (raising `ValueError`) to prevent path injection.
33+
- `server.auth_federation.create_key()` -> `AuthFederationKeyItem` —
34+
generates a new (inactive) signing key; private key is never returned.
35+
Fails with 400 if the site is already at the maximum number of keys.
36+
- `server.auth_federation.activate_key(id)` /
37+
`server.auth_federation.deactivate_key(id)` /
38+
`server.auth_federation.delete_key(id)` accept either a `kid` string
39+
or an `AuthFederationKeyItem` with `kid` set; `activate_key` is atomic
40+
and deactivates any previously active key. `delete_key` fails with 400
41+
if the key is currently active. Safe rotation is `create_key()` →
42+
`activate_key(new_kid)`, then retain the old (now-inactive) key in the
43+
site's JWKS until every JWT it signed has expired (upper bound: the
44+
site's current `token_lifetime_seconds`), and only then
45+
`delete_key(old_kid)` — deleting it earlier removes it from the JWKS
46+
and any verifier fetching the key set afterwards will reject still-
47+
unexpired tokens signed with it. `deactivate_key` on the active key
48+
with no replacement leaves the site unable to mint WIF tokens. The four key-management methods are bound to `NO_ACCESS`
49+
server-side, which only rejects OAuth-scope-restricted sessions
50+
(e.g. connected-app JWT) with 401; password and personal-access-token
51+
(PAT) sessions do not carry scopes and are accepted.
52+
Three new exports: `AuthFederationItem`, `AuthFederationConnectorItem`,
53+
`AuthFederationKeyItem`. Requires Tableau Server REST API 3.30+
54+
(Tableau Server 2026.3), the WIF feature enabled on the site, and the
55+
Site Administrator role on the current site; the server returns 403 if
56+
any of those is missing (including on `get()`).
457
* Added Workload Identity Federation (WIF) support to the connection update
558
endpoints. `ConnectionItem` has two new string properties,
659
`workgroup_auth_mode` and `auth_federation_service_account`, that are
@@ -29,7 +82,7 @@
2982
`ConnectionItem.workgroup_auth_mode` and
3083
`ConnectionItem.auth_federation_service_account` parsed from a response
3184
will typically be `None` even when the connection is WIF-configured.
32-
Requires Tableau Server REST API 3.30+ (Tableau Server release 26.3) with
85+
Requires Tableau Server REST API 3.30+ (Tableau Server 2026.3) with
3386
WIF configured and enabled on the site. Older servers silently ignore
3487
unknown connection attributes, so these fields are safe to leave unset on
3588
pre-3.30 targets.

‎tableauserverclient/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from tableauserverclient.bin._version import get_versions
22
from tableauserverclient.namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE
33
from tableauserverclient.models import (
4+
AuthFederationConnectorItem,
5+
AuthFederationItem,
6+
AuthFederationKeyItem,
47
BackgroundJobItem,
58
CollectionItem,
69
ColumnItem,
@@ -80,6 +83,9 @@
8083
)
8184

8285
__all__ = [
86+
"AuthFederationConnectorItem",
87+
"AuthFederationItem",
88+
"AuthFederationKeyItem",
8389
"BackgroundJobItem",
8490
"CollectionItem",
8591
"ColumnItem",

‎tableauserverclient/models/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
from tableauserverclient.models.auth_federation_item import (
2+
AuthFederationConnectorItem,
3+
AuthFederationItem,
4+
AuthFederationKeyItem,
5+
)
16
from tableauserverclient.models.collection_item import CollectionItem
27
from tableauserverclient.models.column_item import ColumnItem
38
from tableauserverclient.models.connection_credentials import ConnectionCredentials
@@ -55,6 +60,9 @@
5560
from tableauserverclient.models.extract_item import ExtractItem
5661

5762
__all__ = [
63+
"AuthFederationConnectorItem",
64+
"AuthFederationItem",
65+
"AuthFederationKeyItem",
5866
"CollectionItem",
5967
"ColumnItem",
6068
"ConnectionCredentials",
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
from defusedxml.ElementTree import fromstring
2+
3+
from tableauserverclient.helpers.strings import nullable_str_to_bool, nullable_str_to_int
4+
5+
6+
class AuthFederationConnectorItem:
7+
def __init__(self) -> None:
8+
self.luid: str | None = None
9+
self.connector_class: str | None = None
10+
self.audience: str | None = None
11+
12+
def __str__(self) -> str:
13+
return (
14+
f"{self.__class__.__qualname__}(luid={self.luid}, "
15+
f"connector_class={self.connector_class}, audience={self.audience})"
16+
)
17+
18+
def __repr__(self) -> str:
19+
return f"<{str(self)}>"
20+
21+
@classmethod
22+
def from_element(cls, elem) -> "AuthFederationConnectorItem":
23+
connector = cls()
24+
connector.luid = elem.get("luid")
25+
connector.connector_class = elem.get("connectorClass")
26+
connector.audience = elem.get("audience")
27+
return connector
28+
29+
@classmethod
30+
def from_response(cls, raw_xml: bytes, ns) -> "AuthFederationConnectorItem":
31+
root = fromstring(raw_xml)
32+
elem = root.find("t:authFederationConnector", namespaces=ns)
33+
if elem is None:
34+
raise ValueError("No authFederationConnector element found in the XML.")
35+
return cls.from_element(elem)
36+
37+
@classmethod
38+
def list_from_response(cls, raw_xml: bytes, ns) -> list["AuthFederationConnectorItem"]:
39+
root = fromstring(raw_xml)
40+
list_elem = root.find("t:authFederationConnectors", namespaces=ns)
41+
if list_elem is None:
42+
raise ValueError("No authFederationConnectors element found in the XML.")
43+
return [cls.from_element(c) for c in list_elem.findall("t:connector", namespaces=ns)]
44+
45+
46+
class AuthFederationKeyItem:
47+
def __init__(self) -> None:
48+
self.kid: str | None = None
49+
self.kty: str | None = None
50+
self.alg: str | None = None
51+
self.use: str | None = None
52+
self.n: str | None = None
53+
self.e: str | None = None
54+
55+
def __str__(self) -> str:
56+
return f"{self.__class__.__qualname__}(kid={self.kid}, kty={self.kty}, alg={self.alg}, use={self.use})"
57+
58+
def __repr__(self) -> str:
59+
return f"<{str(self)}>"
60+
61+
@classmethod
62+
def from_element(cls, elem) -> "AuthFederationKeyItem":
63+
key = cls()
64+
key.kid = elem.get("kid")
65+
key.kty = elem.get("kty")
66+
key.alg = elem.get("alg")
67+
key.use = elem.get("use")
68+
key.n = elem.get("n")
69+
key.e = elem.get("e")
70+
return key
71+
72+
@classmethod
73+
def from_response(cls, raw_xml: bytes, ns) -> "AuthFederationKeyItem":
74+
root = fromstring(raw_xml)
75+
elem = root.find("t:authFederationKey", namespaces=ns)
76+
if elem is None:
77+
raise ValueError("No authFederationKey element found in the XML.")
78+
return cls.from_element(elem)
79+
80+
81+
class AuthFederationItem:
82+
def __init__(self) -> None:
83+
self.enabled: bool = False
84+
self.issuer_url: str | None = None
85+
self.active_key_id: str | None = None
86+
self.token_lifetime_seconds: int | None = None
87+
self.connectors: list[AuthFederationConnectorItem] = []
88+
89+
def __str__(self) -> str:
90+
return (
91+
f"{self.__class__.__qualname__}(enabled={self.enabled}, "
92+
f"issuer_url={self.issuer_url}, active_key_id={self.active_key_id}, "
93+
f"token_lifetime_seconds={self.token_lifetime_seconds}, "
94+
f"connectors={len(self.connectors)})"
95+
)
96+
97+
def __repr__(self) -> str:
98+
return f"<{str(self)}>"
99+
100+
@classmethod
101+
def from_response(cls, raw_xml: bytes, ns) -> "AuthFederationItem":
102+
root = fromstring(raw_xml)
103+
elem = root.find("t:authFederationAdmin", namespaces=ns)
104+
if elem is None:
105+
raise ValueError("No authFederationAdmin element found in the XML.")
106+
config = cls()
107+
config.enabled = nullable_str_to_bool(elem.get("enabled")) or False
108+
config.issuer_url = elem.get("issuerUrl")
109+
config.active_key_id = elem.get("activeKeyId")
110+
config.token_lifetime_seconds = nullable_str_to_int(elem.get("tokenLifetimeSeconds"))
111+
connectors_elem = elem.find("t:connectors", namespaces=ns)
112+
if connectors_elem is not None:
113+
config.connectors = [
114+
AuthFederationConnectorItem.from_element(c)
115+
for c in connectors_elem.findall("t:connector", namespaces=ns)
116+
]
117+
return config

‎tableauserverclient/server/endpoint/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from tableauserverclient.server.endpoint.auth_endpoint import Auth
2+
from tableauserverclient.server.endpoint.auth_federation_endpoint import AuthFederation
23
from tableauserverclient.server.endpoint.custom_views_endpoint import CustomViews
34
from tableauserverclient.server.endpoint.data_acceleration_report_endpoint import DataAccelerationReport
45
from tableauserverclient.server.endpoint.data_alert_endpoint import DataAlerts
@@ -35,6 +36,7 @@
3536

3637
__all__ = [
3738
"Auth",
39+
"AuthFederation",
3840
"CustomViews",
3941
"DataAccelerationReport",
4042
"DataAlerts",

0 commit comments

Comments
 (0)