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
52 changes: 37 additions & 15 deletions tableauserverclient/models/user_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,36 +432,37 @@ class ColumnType(IntEnum):
EMAIL = 6
AUTH = 7

MAX = 7
MAX = 8 # total number of columns (not last index)

# Read a csv line and create a user item populated by the given attributes
@staticmethod
def create_user_from_line(line: str):
if line is None or line is False or line == "\n" or line == "":
return None
line = line.strip().lower()
values: list[str] = list(map(str.strip, line.split(",")))
user = UserItem(values[UserItem.CSVImport.ColumnType.USERNAME])
values: list[str] = list(map(str.strip, line.strip().split(",")))
if len(values) > UserItem.CSVImport.ColumnType.MAX:
raise ValueError("Too many attributes for user import")
username = values[UserItem.CSVImport.ColumnType.USERNAME]
user = UserItem(username)
if len(values) > 1:
if len(values) > UserItem.CSVImport.ColumnType.MAX:
raise ValueError("Too many attributes for user import")
while len(values) <= UserItem.CSVImport.ColumnType.MAX:
while len(values) < UserItem.CSVImport.ColumnType.MAX:
values.append("")
site_role = UserItem.CSVImport._evaluate_site_role(
values[UserItem.CSVImport.ColumnType.LICENSE],
values[UserItem.CSVImport.ColumnType.ADMIN],
values[UserItem.CSVImport.ColumnType.PUBLISHER],
)

raw_auth = values[UserItem.CSVImport.ColumnType.AUTH]
auth = UserItem.CSVImport._auth_canonical().get(raw_auth.lower()) if raw_auth else None
user._set_values(
None,
values[UserItem.CSVImport.ColumnType.USERNAME],
username,
site_role,
None,
None,
values[UserItem.CSVImport.ColumnType.DISPLAY_NAME],
values[UserItem.CSVImport.ColumnType.EMAIL],
values[UserItem.CSVImport.ColumnType.AUTH],
auth,
None,
None,
None,
Expand Down Expand Up @@ -491,6 +492,16 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, l

# Some fields in the import file are restricted to specific values
# Iterate through each field and validate the given value against hardcoded constraints
@staticmethod
def _auth_canonical() -> dict[str, str]:
"""Lowercase → canonical form mapping for Auth values."""
return {
"saml": "SAML",
"openid": "OpenID",
"serverdefault": "ServerDefault",
"tableauidwithmfa": "TableauIDWithMFA",
}

@staticmethod
def _validate_import_line_or_throw(incoming, logger) -> None:
_valid_attributes: list[list[str]] = [
Expand All @@ -501,7 +512,12 @@ def _validate_import_line_or_throw(incoming, logger) -> None:
["system", "site", "none", "no"], # admin
["yes", "true", "1", "no", "false", "0"], # publisher
[],
[UserItem.Auth.SAML, UserItem.Auth.OpenID, UserItem.Auth.ServerDefault], # auth
[
"SAML",
"OpenID",
"ServerDefault",
"TableauIDWithMFA",
], # auth — normalized by _auth_canonical before comparison
]

line = list(map(str.strip, incoming.split(",")))
Expand All @@ -511,10 +527,16 @@ def _validate_import_line_or_throw(incoming, logger) -> None:
logger.debug(f"> details - {username}")
UserItem.validate_username_or_throw(username)
for i in range(1, len(line)):
logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {line[i]}")
UserItem.CSVImport._validate_attribute_value(
line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i)
)
value = line[i]
valid = _valid_attributes[i]
# normalize case for fields with a restricted value set
if valid:
if i == UserItem.CSVImport.ColumnType.AUTH:
value = UserItem.CSVImport._auth_canonical().get(value.lower(), value)
else:
value = value.lower()
logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {value}")
UserItem.CSVImport._validate_attribute_value(value, valid, UserItem.CSVImport.ColumnType(i))

# Given a restricted set of possible values, confirm the item is in that set
@staticmethod
Expand Down
3 changes: 3 additions & 0 deletions tableauserverclient/server/endpoint/endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,9 @@ def fields(self: Self, *fields: str) -> QuerySet:
queryset.request_options.fields |= set(fields) | set(("_default_",))
return queryset

def find_by_name(self, name: str) -> list[T]:
return list(self.filter(name=name))

def only_fields(self: Self, *fields: str) -> QuerySet:
"""
Add fields to the request options. If no fields are provided, the
Expand Down
33 changes: 33 additions & 0 deletions test/test_user_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,36 @@ def test_validate_usernames_file() -> None:
test_data = _mock_file_content(usernames)
valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, logger)
assert valid == 5, f"Exactly 5 of the lines were valid, counted {valid + len(invalid)}"


def test_validate_mixed_case_license() -> None:
# Regression: issue #1809 — 'Viewer' (capital V) was rejected by case-sensitive check
TSC.UserItem.CSVImport._validate_import_line_or_throw("username, pword, fname, Viewer, None, no, email", logger)
TSC.UserItem.CSVImport._validate_import_line_or_throw("username, pword, fname, Creator, Site, yes, email", logger)
TSC.UserItem.CSVImport._validate_import_line_or_throw("username, pword, fname, EXPLORER, NONE, YES, email", logger)


def test_validate_tableauid_with_mfa_auth() -> None:
# TableauIDWithMFA is a valid auth value and must not be rejected
TSC.UserItem.CSVImport._validate_import_line_or_throw(
"username, pword, fname, creator, none, yes, email, TableauIDWithMFA", logger
)


def test_create_user_preserves_username_case() -> None:
# Username must not be lowercased — case matters for LDAP and email-format usernames
user = TSC.UserItem.CSVImport.create_user_from_line("JSmith, pword, John Smith, creator, none, yes, j@example.com")
assert user is not None
assert user.name == "JSmith", f"Username was lowercased: {user.name}"


def test_create_user_with_auth_column() -> None:
# AUTH column (position 7) must be parsed — was broken by MAX=7 off-by-one
user = TSC.UserItem.CSVImport.create_user_from_line("username, pword, fname, creator, none, yes, email, SAML")
assert user is not None
assert user.auth_setting == "SAML", f"Expected SAML, got {user.auth_setting}"


def test_too_many_columns_raises() -> None:
with pytest.raises((ValueError, AttributeError)):
TSC.UserItem.CSVImport.create_user_from_line("u, p, n, creator, none, yes, email, SAML, extra")
Loading