Skip to content

Commit eafeece

Browse files
committed
Merge remote-tracking branch 'origin/development' into jac/1046-remove-old-namespace
# Conflicts: # tableauserverclient/server/endpoint/auth_endpoint.py
2 parents ef3ef1f + e29d81e commit eafeece

10 files changed

Lines changed: 691 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@
55
hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by
66
level using the REST API name filter, so a path with *n* components issues *n*
77
requests. Returns the matching `ProjectItem` or `None` if no project is found.
8+
* Preserve HTTP method and body across 3xx redirects. Previously `requests`
9+
followed 301/302/303 by converting POST to GET and dropping the body, so
10+
endpoints like `users.add`, `workbooks.publish`, and any write hitting a
11+
server behind a redirect would 405. TSC now disables `requests`'s
12+
auto-redirect and walks the chain manually, up to `session.max_redirects`
13+
hops (default 30). Refuses HTTPS -> HTTP scheme downgrades and raises
14+
`RedirectError` with a clear message on missing `Location` headers or hop
15+
overflow. Fixes #1127 and #1828.
16+
* `UserItem.CSVImport.create_user_from_line` no longer
17+
lowercases the entire CSV line before parsing. Previously the whole line,
18+
including the username, display name, fullname, and email fields, was
19+
lowercased destructively (e.g. `JSmith` became `jsmith`). Case is now
20+
preserved for those fields; only the comparison-relevant fields (license,
21+
admin_level, publisher, auth_setting) are normalized internally for
22+
validation. Callers relying on the previous lowercased output -- e.g. dict
23+
lookups keyed on `user.name`, or assertions against lowercased values --
24+
need to update. This unblocks CSV imports for LDAP and other case-sensitive
25+
auth backends where mixed-case usernames must be preserved.
26+
* `UserItem.CSVImport._validate_attribute_value` and the too-many-columns
27+
branch of `_validate_import_line_or_throw` now raise `ValueError` instead
28+
of `AttributeError` for invalid CSV input. `AttributeError` was the wrong
29+
exception type for input validation and inconsistent with
30+
`create_user_from_line`. `validate_file_for_import` catches `Exception` so
31+
it is unaffected; direct callers who caught `AttributeError` specifically
32+
need to widen their handler.
833
* Added `JobItem.status_notes` for the structured `<statusNotes><statusNote
934
type=".." value=".." text=".."/></statusNotes>` block documented on the Query
1035
Job REST endpoint. Populated for UserImport and other multi-row jobs where

tableauserverclient/models/user_item.py

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,14 @@ def _set_values(
308308
if email:
309309
self.email = email
310310
if auth_setting:
311+
# Write directly to _auth_setting rather than going through the
312+
# @property_is_enum(Auth) setter. This method is called from both
313+
# CSV import and server response parsing (from_xml, populate, etc.);
314+
# if the server ever returns an auth type we don't yet know about
315+
# (a new Auth value in a future Tableau release), the enum guard
316+
# would raise ValueError during response parsing. CSV callers
317+
# already validate the auth string against CSVImport._AUTH_CANONICAL
318+
# before calling here, so this write is safe.
311319
self._auth_setting = auth_setting
312320
if domain_name:
313321
self._domain_name = domain_name
@@ -432,36 +440,59 @@ class ColumnType(IntEnum):
432440
EMAIL = 6
433441
AUTH = 7
434442

435-
MAX = 7
443+
# Total number of columns supported by the import format. Held outside
444+
# the ColumnType enum so it can't be mistaken for a real column index.
445+
COLUMN_COUNT = 8
446+
447+
# Lowercase -> canonical form mapping for the AUTH column. Class-level
448+
# so the dict isn't rebuilt on every call to create_user_from_line /
449+
# _validate_import_line_or_throw. The set of accepted values is derived
450+
# from this map (see _valid_attributes[AUTH]) so there's a single
451+
# source of truth.
452+
_AUTH_CANONICAL: dict[str, str] = {
453+
"saml": "SAML",
454+
"openid": "OpenID",
455+
"serverdefault": "ServerDefault",
456+
"tableauidwithmfa": "TableauIDWithMFA",
457+
}
436458

437459
# Read a csv line and create a user item populated by the given attributes
438460
@staticmethod
439461
def create_user_from_line(line: str):
440462
if line is None or line is False or line == "\n" or line == "":
441463
return None
442-
line = line.strip().lower()
443-
values: list[str] = list(map(str.strip, line.split(",")))
444-
user = UserItem(values[UserItem.CSVImport.ColumnType.USERNAME])
464+
values: list[str] = list(map(str.strip, line.strip().split(",")))
465+
if len(values) > UserItem.CSVImport.COLUMN_COUNT:
466+
raise ValueError("Too many attributes for user import")
467+
username = values[UserItem.CSVImport.ColumnType.USERNAME]
468+
user = UserItem(username)
445469
if len(values) > 1:
446-
if len(values) > UserItem.CSVImport.ColumnType.MAX:
447-
raise ValueError("Too many attributes for user import")
448-
while len(values) <= UserItem.CSVImport.ColumnType.MAX:
470+
while len(values) < UserItem.CSVImport.COLUMN_COUNT:
449471
values.append("")
450472
site_role = UserItem.CSVImport._evaluate_site_role(
451473
values[UserItem.CSVImport.ColumnType.LICENSE],
452474
values[UserItem.CSVImport.ColumnType.ADMIN],
453475
values[UserItem.CSVImport.ColumnType.PUBLISHER],
454476
)
455-
477+
raw_auth = values[UserItem.CSVImport.ColumnType.AUTH]
478+
if raw_auth:
479+
auth = UserItem.CSVImport._AUTH_CANONICAL.get(raw_auth.lower())
480+
if auth is None:
481+
raise ValueError(
482+
f"Unknown auth setting: {raw_auth!r}. "
483+
f"Valid values: {sorted(UserItem.CSVImport._AUTH_CANONICAL.values())}"
484+
)
485+
else:
486+
auth = None
456487
user._set_values(
457488
None,
458-
values[UserItem.CSVImport.ColumnType.USERNAME],
489+
username,
459490
site_role,
460491
None,
461492
None,
462493
values[UserItem.CSVImport.ColumnType.DISPLAY_NAME],
463494
values[UserItem.CSVImport.ColumnType.EMAIL],
464-
values[UserItem.CSVImport.ColumnType.AUTH],
495+
auth,
465496
None,
466497
None,
467498
None,
@@ -493,6 +524,8 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, l
493524
# Iterate through each field and validate the given value against hardcoded constraints
494525
@staticmethod
495526
def _validate_import_line_or_throw(incoming, logger) -> None:
527+
# AUTH column's valid set is derived from _AUTH_CANONICAL so there's
528+
# one source of truth for the accepted values.
496529
_valid_attributes: list[list[str]] = [
497530
[],
498531
[],
@@ -501,20 +534,26 @@ def _validate_import_line_or_throw(incoming, logger) -> None:
501534
["system", "site", "none", "no"], # admin
502535
["yes", "true", "1", "no", "false", "0"], # publisher
503536
[],
504-
[UserItem.Auth.SAML, UserItem.Auth.OpenID, UserItem.Auth.ServerDefault], # auth
537+
list(UserItem.CSVImport._AUTH_CANONICAL.values()), # auth - normalized before comparison
505538
]
506539

507540
line = list(map(str.strip, incoming.split(",")))
508-
if len(line) > UserItem.CSVImport.ColumnType.MAX:
509-
raise AttributeError("Too many attributes in line")
541+
if len(line) > UserItem.CSVImport.COLUMN_COUNT:
542+
raise ValueError("Too many attributes for user import")
510543
username = line[UserItem.CSVImport.ColumnType.USERNAME.value]
511544
logger.debug(f"> details - {username}")
512545
UserItem.validate_username_or_throw(username)
513546
for i in range(1, len(line)):
514-
logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {line[i]}")
515-
UserItem.CSVImport._validate_attribute_value(
516-
line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i)
517-
)
547+
value = line[i]
548+
valid = _valid_attributes[i]
549+
# normalize case for fields with a restricted value set
550+
if valid:
551+
if i == UserItem.CSVImport.ColumnType.AUTH:
552+
value = UserItem.CSVImport._AUTH_CANONICAL.get(value.lower(), value)
553+
else:
554+
value = value.lower()
555+
logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {value}")
556+
UserItem.CSVImport._validate_attribute_value(value, valid, UserItem.CSVImport.ColumnType(i))
518557

519558
# Given a restricted set of possible values, confirm the item is in that set
520559
@staticmethod
@@ -524,7 +563,7 @@ def _validate_attribute_value(item: str, possible_values: list[str], column_type
524563
return
525564
if item in possible_values or possible_values == []:
526565
return
527-
raise AttributeError(f"Invalid value {item} for {column_type}")
566+
raise ValueError(f"Invalid value {item} for {column_type}")
528567

529568
# https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles
530569
# This logic is hardcoded to match the existing rules for import csv files

tableauserverclient/server/endpoint/auth_endpoint.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from defusedxml.ElementTree import fromstring
66

7-
from tableauserverclient.server.endpoint.endpoint import Endpoint, api
7+
from tableauserverclient.server.endpoint.endpoint import Endpoint, XML_CONTENT_TYPE, api
88
from tableauserverclient.server.endpoint.exceptions import ServerResponseError
99
from tableauserverclient.server.request_factory import RequestFactory
1010

@@ -68,19 +68,18 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr:
6868
"""
6969
url = f"{self.baseurl}/signin"
7070
signin_req = RequestFactory.Auth.signin_req(auth_req)
71-
server_response = self.parent_srv.session.post(
72-
url, data=signin_req, **self.parent_srv.http_options, allow_redirects=False
71+
# Route through _make_request so signin gets the same redirect handling
72+
# (multi-hop, HTTPS->HTTP scheme guard, missing-Location diagnostic,
73+
# hop limit) that every other endpoint uses. Explicit auth_token=None
74+
# because we don't have one yet -- and self.parent_srv.auth_token
75+
# raises NotSignedInError pre-signin, so post_request can't help here.
76+
server_response = self._make_request(
77+
self.parent_srv.session.post,
78+
url,
79+
content=signin_req,
80+
auth_token=None,
81+
content_type=XML_CONTENT_TYPE,
7382
)
74-
# manually handle a redirect so that we send the correct POST request instead of GET
75-
# this will make e.g http://online.tableau.com work to redirect to http://east.online.tableau.com
76-
if server_response.status_code == 301:
77-
server_response = self.parent_srv.session.post(
78-
server_response.headers["Location"],
79-
data=signin_req,
80-
**self.parent_srv.http_options,
81-
allow_redirects=False,
82-
)
83-
self._check_status(server_response, url)
8483
parsed_response = fromstring(server_response.content)
8584
site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None)
8685
site_url = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("contentUrl", None)

0 commit comments

Comments
 (0)