Skip to content

Commit df9bc24

Browse files
authored
fix(API): Export roles (#386)
1 parent 8b58a70 commit df9bc24

8 files changed

Lines changed: 407 additions & 216 deletions

File tree

src/preset_cli/api/clients/superset.py

Lines changed: 147 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,17 @@
4949
from yarl import URL
5050

5151
from preset_cli import __version__
52-
from preset_cli.api.clients.preset import PresetClient
5352
from preset_cli.api.operators import Equal, In, Operator
5453
from preset_cli.auth.main import Auth
54+
from preset_cli.exceptions import SupersetError
5555
from preset_cli.lib import remove_root, validate_response
5656
from preset_cli.typing import UserType
5757

5858
_logger = logging.getLogger(__name__)
5959

6060
MAX_PAGE_SIZE = 100
6161
MAX_IDS_IN_EXPORT = 50
62-
63-
62+
PRESET_WORKSPACE_DOMAIN = "preset.io"
6463
PERMISSION_MAP = {
6564
"all datasource access on all_datasource_access": "All dataset access",
6665
"all database access on all_database_access": "All database access",
@@ -77,6 +76,14 @@
7776
)
7877

7978

79+
def format_permission(permission: str, view_menu: str) -> str:
80+
"""
81+
Convert the permission name from API responses into human-readable
82+
values for role exports.
83+
"""
84+
return f"{permission.replace('_', ' ')} on {view_menu}"
85+
86+
8087
class GenericDataType(IntEnum):
8188
"""
8289
Generic database column type that fits both frontend and backend.
@@ -205,6 +212,7 @@ class RoleType(TypedDict):
205212
name: str
206213
permissions: List[str]
207214
users: List[str]
215+
groups: List[str]
208216

209217

210218
class RuleType(TypedDict):
@@ -750,11 +758,36 @@ def delete_dashboard(self, dashboard_id: int) -> Any:
750758
"""
751759
self.delete_resource("dashboard", dashboard_id)
752760

753-
def get_users(self, **kwargs: str) -> List[Any]:
761+
def get_users(self, **kwargs: str) -> List[UserType]:
762+
"""
763+
Return users.
764+
765+
Tries the REST API first (``/api/v1/security/users/``), which works for both
766+
Preset Cloud and recent Superset versions. Older Superset instances don't expose
767+
this endpoint, so we fall back to crawling the CRUD HTML page. Filtering is only
768+
supported for the API path.
769+
"""
770+
try:
771+
return self._get_users_api(**kwargs)
772+
except SupersetError:
773+
return self._get_users_legacy()
774+
775+
def _get_users_api(self, **kwargs: str) -> List[UserType]:
754776
"""
755777
Return users, possibly filtered.
756778
"""
757-
return self.get_resources("security/users", "id", **kwargs)
779+
return [
780+
{
781+
"id": user["id"],
782+
"first_name": user["first_name"],
783+
"last_name": user["last_name"],
784+
"username": user["username"],
785+
"email": user["email"],
786+
"role": [role["name"] for role in user.get("roles", [])],
787+
# TODO: Include groups as well
788+
}
789+
for user in self.get_resources("security/users", "id", **kwargs)
790+
]
758791

759792
def get_report(self, report_id: int) -> Any:
760793
"""
@@ -873,33 +906,19 @@ def get_rls(self, **kwargs: str) -> List[Any]:
873906
"""
874907
return self.get_resources("rowlevelsecurity", **kwargs)
875908

876-
def export_users(self) -> Iterator[UserType]:
909+
def _is_preset_workspace(self) -> bool:
877910
"""
878-
Return all users.
911+
Return whether the target is a Preset workspace or a Superset instance.
879912
"""
880-
# For on-premise OSS Superset we can fetch the list of users by crawling the
881-
# ``/users/list/`` page. For a Preset workspace we need custom logic to talk
882-
# to Manager.
883-
url = self.baseurl / "users/list/"
884-
_logger.debug("GET %s", url)
885-
response = self.session.get(url)
886-
if response.ok:
887-
return self._export_users_superset()
888-
return self._export_users_preset()
889-
890-
def _export_users_preset(self) -> Iterator[UserType]:
891-
"""
892-
Return all users from a Preset workspace.
893-
"""
894-
client = PresetClient(self.preset_baseurl, self.auth)
895-
return client.export_users(self.baseurl)
913+
return str(self.baseurl.host).endswith(f".{PRESET_WORKSPACE_DOMAIN}")
896914

897-
def _export_users_superset(self) -> Iterator[UserType]:
915+
def _get_users_legacy(self) -> List[UserType]:
898916
"""
899-
Return all users from a standalone Superset instance.
917+
Return all users from an older Superset instance.
900918
901919
Since this is not exposed via an API we need to crawl the CRUD page.
902920
"""
921+
users: List[UserType] = []
903922
page = 0
904923
while True:
905924
params = {
@@ -919,30 +938,108 @@ def _export_users_superset(self) -> Iterator[UserType]:
919938

920939
for tr in trs[1:]: # pylint: disable=invalid-name
921940
tds = tr.find_all("td")
922-
yield {
923-
"id": int(tds[0].find("a").attrs["href"].split("/")[-1]),
924-
"first_name": tds[1].text,
925-
"last_name": tds[2].text,
926-
"username": tds[3].text,
927-
"email": tds[4].text,
928-
"role": parse_html_array(tds[6].text.strip()),
929-
}
941+
users.append(
942+
{
943+
"id": int(tds[0].find("a").attrs["href"].split("/")[-1]),
944+
"first_name": tds[1].text,
945+
"last_name": tds[2].text,
946+
"username": tds[3].text,
947+
"email": tds[4].text,
948+
"role": parse_html_array(tds[6].text.strip()),
949+
},
950+
)
951+
return users
930952

931-
def export_roles(self) -> Iterator[RoleType]: # pylint: disable=too-many-locals
953+
def export_roles(self) -> List[RoleType]:
932954
"""
933955
Return all roles.
934956
"""
935-
user_email_map = {user["id"]: user["email"] for user in self.export_users()}
957+
if self._is_preset_workspace():
958+
return self._export_dars_preset()
959+
960+
try:
961+
return self._export_roles_superset()
962+
except SupersetError:
963+
return self._export_roles_legacy()
936964

965+
def _export_roles_superset(self) -> List[RoleType]:
966+
"""
967+
Return all roles from a standalone Superset instance via the REST API.
968+
"""
969+
user_email_map = {user["id"]: user["email"] for user in self.get_users()}
970+
perm_map = {
971+
perm["id"]: format_permission(
972+
perm["permission"]["name"],
973+
perm["view_menu"]["name"],
974+
)
975+
for perm in self.get_resources("security/permissions-resources", "id")
976+
}
977+
group_map = {
978+
group["id"]: group["name"]
979+
for group in self.get_resources("security/groups", "id")
980+
}
981+
982+
roles: List[RoleType] = []
983+
for role in self.get_resources("security/roles/search", "id"):
984+
roles.append(
985+
{
986+
"name": role["name"],
987+
"permissions": [
988+
perm_map[perm_id]
989+
for perm_id in role["permission_ids"]
990+
if perm_id in perm_map
991+
],
992+
"users": [
993+
user_email_map[user_id]
994+
for user_id in role["user_ids"]
995+
if user_id in user_email_map
996+
],
997+
"groups": [
998+
group_map[group_id]
999+
for group_id in role["group_ids"]
1000+
if group_id in group_map
1001+
],
1002+
},
1003+
)
1004+
return roles
1005+
1006+
def _export_dars_preset(self) -> List[RoleType]:
1007+
"""
1008+
Return all DARs from a Preset workspace via the DAR API.
1009+
"""
1010+
user_email_map = {user["id"]: user["email"] for user in self.get_users()}
1011+
roles: List[RoleType] = []
1012+
for role in self.get_resources("security/dar", "name"):
1013+
roles.append(
1014+
{
1015+
"name": role["name"],
1016+
"permissions": [
1017+
format_permission(perm["permission"], perm["view_menu"])
1018+
for perm in role["permissions"]
1019+
],
1020+
"users": [
1021+
user_email_map[user["id"]]
1022+
for user in role["users"]
1023+
if user["id"] in user_email_map
1024+
],
1025+
"groups": [group["name"] for group in role.get("groups", [])],
1026+
},
1027+
)
1028+
return roles
1029+
1030+
def _export_roles_legacy( # pylint: disable=too-many-locals
1031+
self,
1032+
) -> List[RoleType]:
1033+
"""
1034+
Return all roles by crawling the CRUD HTML pages (older Superset versions).
1035+
"""
1036+
user_email_map = {user["id"]: user["email"] for user in self.get_users()}
1037+
roles: List[RoleType] = []
9371038
page = 0
9381039
while True:
9391040
params = {
940-
# Superset
9411041
"psize_RoleModelView": MAX_PAGE_SIZE,
9421042
"page_RoleModelView": page,
943-
# Preset
944-
"psize_DataRoleModelView": MAX_PAGE_SIZE,
945-
"page_DataRoleModelView": page,
9461043
}
9471044
url = self.baseurl / "roles/list/"
9481045
page += 1
@@ -984,11 +1081,16 @@ def export_roles(self) -> Iterator[RoleType]: # pylint: disable=too-many-locals
9841081
and int(option.attrs["value"]) in user_email_map
9851082
]
9861083

987-
yield {
988-
"name": name,
989-
"permissions": permissions,
990-
"users": users,
991-
}
1084+
roles.append(
1085+
{
1086+
"name": name,
1087+
"permissions": permissions,
1088+
"users": users,
1089+
# Legacy Superset instances didn't support groups
1090+
"groups": [],
1091+
},
1092+
)
1093+
return roles
9921094

9931095
def export_rls_legacy(self) -> Iterator[RuleType]:
9941096
"""
@@ -1095,7 +1197,7 @@ def import_role(self, role: RoleType) -> None: # pylint: disable=too-many-local
10951197
Note: this only works with Preset workspaces for now, since it translates the
10961198
Superset permissions to the Preset permissions.
10971199
"""
1098-
user_id_map = {user["email"]: user["id"] for user in self.export_users()}
1200+
user_id_map = {user["email"]: user["id"] for user in self.get_users()}
10991201
user_ids = [
11001202
user_id_map[email] for email in role["users"] if email in user_id_map
11011203
]

src/preset_cli/cli/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,7 @@ def sync_all_user_roles_to_team( # pylint: disable=too-many-locals
759759
superset_client = SupersetClient(f"https://{workspace_hostname}/", client.auth)
760760

761761
user_id_map = {
762-
user["email"]: user["id"] for user in superset_client.export_users()
762+
user["email"]: user["id"] for user in superset_client.get_users()
763763
}
764764
for data_access_role, user_emails in workspace_data_access_roles.items():
765765
role_id = superset_client.get_role_id(data_access_role)

src/preset_cli/cli/superset/export.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -623,7 +623,7 @@ def export_users(
623623
client = SupersetClient(url, auth, preset_baseurl)
624624

625625
users = [
626-
{k: v for k, v in user.items() if k != "id"} for user in client.export_users()
626+
{k: v for k, v in user.items() if k != "id"} for user in client.get_users()
627627
]
628628

629629
newline = get_newline_char(force_unix_eol)
@@ -747,7 +747,7 @@ def export_ownership( # pylint: disable=too-many-locals, too-many-arguments
747747
url = URL(ctx.obj["INSTANCE"])
748748
client = SupersetClient(url, auth)
749749

750-
users = {user["id"]: user["email"] for user in client.export_users()}
750+
users = {user["id"]: user["email"] for user in client.get_users()}
751751

752752
asset_types = set(asset_type)
753753
ids = {

src/preset_cli/cli/superset/import_.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def import_ownership( # pylint: disable=too-many-locals
9393
with open(path, encoding="utf-8") as input_:
9494
config = yaml.load(input_, Loader=yaml.SafeLoader)
9595

96-
users = {user["email"]: user["id"] for user in client.export_users()}
96+
users = {user["email"]: user["id"] for user in client.get_users()}
9797
with open(log_file_path, "w", encoding="utf-8") as log_file:
9898
for resource_name, resources in config.items():
9999
resource_ids = {

0 commit comments

Comments
 (0)