4949from yarl import URL
5050
5151from preset_cli import __version__
52- from preset_cli .api .clients .preset import PresetClient
5352from preset_cli .api .operators import Equal , In , Operator
5453from preset_cli .auth .main import Auth
54+ from preset_cli .exceptions import SupersetError
5555from preset_cli .lib import remove_root , validate_response
5656from preset_cli .typing import UserType
5757
5858_logger = logging .getLogger (__name__ )
5959
6060MAX_PAGE_SIZE = 100
6161MAX_IDS_IN_EXPORT = 50
62-
63-
62+ PRESET_WORKSPACE_DOMAIN = "preset.io"
6463PERMISSION_MAP = {
6564 "all datasource access on all_datasource_access" : "All dataset access" ,
6665 "all database access on all_database_access" : "All database access" ,
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+
8087class 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
210218class 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 ]
0 commit comments