diff --git a/client/src/api/roles.ts b/client/src/api/roles.ts index 61fde1360a92..4667ca53a2b5 100644 --- a/client/src/api/roles.ts +++ b/client/src/api/roles.ts @@ -5,3 +5,7 @@ export async function getAllRoles() { const { data } = await getRoles({}); return data; } + +export const deleteRole = fetcher.path("/api/roles/{id}").method("delete").create(); +export const purgeRole = fetcher.path("/api/roles/{id}/purge").method("post").create(); +export const undeleteRole = fetcher.path("/api/roles/{id}/undelete").method("post").create(); diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 697f187d4188..cb551a3c25e3 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -1361,6 +1361,16 @@ export interface paths { "/api/roles/{id}": { /** Show */ get: operations["show_api_roles__id__get"]; + /** Delete */ + delete: operations["delete_api_roles__id__delete"]; + }; + "/api/roles/{id}/purge": { + /** Purge */ + post: operations["purge_api_roles__id__purge_post"]; + }; + "/api/roles/{id}/undelete": { + /** Undelete */ + post: operations["undelete_api_roles__id__undelete_post"]; }; "/api/short_term_storage/{storage_request_id}": { /** Serve the staged download specified by request ID. */ @@ -17622,6 +17632,84 @@ export interface operations { }; }; }; + delete_api_roles__id__delete: { + /** Delete */ + parameters: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string; + }; + path: { + id: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["RoleModelResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + purge_api_roles__id__purge_post: { + /** Purge */ + parameters: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string; + }; + path: { + id: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["RoleModelResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + undelete_api_roles__id__undelete_post: { + /** Undelete */ + parameters: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string; + }; + path: { + id: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["RoleModelResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; serve_api_short_term_storage__storage_request_id__get: { /** Serve the staged download specified by request ID. */ parameters: { diff --git a/client/src/components/Grid/configs/adminRoles.ts b/client/src/components/Grid/configs/adminRoles.ts new file mode 100644 index 000000000000..b1365e27b7c9 --- /dev/null +++ b/client/src/components/Grid/configs/adminRoles.ts @@ -0,0 +1,187 @@ +import { faEdit, faKey, faPlus, faTrash, faTrashRestore } from "@fortawesome/free-solid-svg-icons"; +import { useEventBus } from "@vueuse/core"; +import axios from "axios"; + +import { deleteRole, purgeRole, undeleteRole } from "@/api/roles"; +import Filtering, { contains, equals, toBool, type ValidFilter } from "@/utils/filtering"; +import { withPrefix } from "@/utils/redirect"; +import { errorMessageAsString } from "@/utils/simple-error"; + +import type { ActionArray, FieldArray, GridConfig } from "./types"; + +const { emit } = useEventBus("grid-router-push"); + +/** + * Local types + */ +type RoleEntry = Record; + +/** + * Request and return data from server + */ +async function getData(offset: number, limit: number, search: string, sort_by: string, sort_desc: boolean) { + const query = { + limit: String(limit), + offset: String(offset), + search: search, + sort_by: sort_by, + sort_desc: String(sort_desc), + }; + const queryString = new URLSearchParams(query).toString(); + const { data } = await axios.get(withPrefix(`/admin/roles_list?${queryString}`)); + return [data.rows, data.rows_total]; +} + +/** + * Actions are grid-wide operations + */ +const actions: ActionArray = [ + { + title: "Create New Role", + icon: faPlus, + handler: () => { + emit("/admin/form/create_role"); + }, + }, +]; + +/** + * Declare columns to be displayed + */ +const fields: FieldArray = [ + { + key: "name", + title: "Name", + type: "operations", + operations: [ + { + title: "Edit Name/Description", + icon: faEdit, + condition: (data: RoleEntry) => !data.deleted, + handler: (data: RoleEntry) => { + emit(`/admin/form/rename_role?id=${data.id}`); + }, + }, + { + title: "Edit Permissions", + icon: faKey, + condition: (data: RoleEntry) => !data.deleted, + handler: (data: RoleEntry) => { + emit(`/admin/form/manage_users_and_groups_for_role?id=${data.id}`); + }, + }, + { + title: "Delete", + icon: faTrash, + condition: (data: RoleEntry) => !data.deleted, + handler: async (data: RoleEntry) => { + try { + await deleteRole({ id: String(data.id) }); + return { + status: "success", + message: `'${data.name}' has been deleted.`, + }; + } catch (e) { + return { + status: "danger", + message: `Failed to delete '${data.name}': ${errorMessageAsString(e)}`, + }; + } + }, + }, + { + title: "Purge", + icon: faTrash, + condition: (data: RoleEntry) => !!data.deleted, + handler: async (data: RoleEntry) => { + try { + await purgeRole({ id: String(data.id) }); + return { + status: "success", + message: `'${data.name}' has been purged.`, + }; + } catch (e) { + return { + status: "danger", + message: `Failed to purge '${data.name}': ${errorMessageAsString(e)}`, + }; + } + }, + }, + { + title: "Restore", + icon: faTrashRestore, + condition: (data: RoleEntry) => !!data.deleted, + handler: async (data: RoleEntry) => { + try { + await undeleteRole({ id: String(data.id) }); + return { + status: "success", + message: `'${data.name}' has been restored.`, + }; + } catch (e) { + return { + status: "danger", + message: `Failed to restore '${data.name}': ${errorMessageAsString(e)}`, + }; + } + }, + }, + ], + }, + { + key: "description", + title: "Description", + type: "text", + }, + { + key: "type", + title: "Type", + type: "text", + }, + { + key: "groups", + title: "Groups", + type: "text", + }, + { + key: "users", + title: "Users", + type: "text", + }, + { + key: "update_time", + title: "Updated", + type: "date", + }, +]; + +const validFilters: Record> = { + name: { placeholder: "name", type: String, handler: contains("name"), menuItem: true }, + description: { placeholder: "description", type: String, handler: contains("description"), menuItem: true }, + deleted: { + placeholder: "Filter on deleted entries", + type: Boolean, + boolType: "is", + handler: equals("deleted", "deleted", toBool), + menuItem: true, + }, +}; + +/** + * Grid configuration + */ +const gridConfig: GridConfig = { + id: "roles-grid", + actions: actions, + fields: fields, + filtering: new Filtering(validFilters, undefined, false, false), + getData: getData, + plural: "Roles", + sortBy: "name", + sortDesc: true, + sortKeys: ["description", "name", "update_time"], + title: "Roles", +}; + +export default gridConfig; diff --git a/client/src/entry/analysis/routes/admin-routes.js b/client/src/entry/analysis/routes/admin-routes.js index 7d30e68abc59..7be38abcfc62 100644 --- a/client/src/entry/analysis/routes/admin-routes.js +++ b/client/src/entry/analysis/routes/admin-routes.js @@ -18,6 +18,7 @@ import NotificationsManagement from "components/admin/Notifications/Notification import ResetMetadata from "components/admin/ResetMetadata"; import SanitizeAllow from "components/admin/SanitizeAllow"; import FormGeneric from "components/Form/FormGeneric"; +import adminRolesGridConfig from "components/Grid/configs/adminRoles"; import adminUsersGridConfig from "components/Grid/configs/adminUsers"; import Grid from "components/Grid/Grid"; import GridList from "components/Grid/GridList"; @@ -148,9 +149,9 @@ export default [ }, { path: "roles", - component: Grid, + component: GridList, props: { - urlBase: "admin/roles_list", + gridConfig: adminRolesGridConfig, }, }, { diff --git a/lib/galaxy/managers/roles.py b/lib/galaxy/managers/roles.py index fbe96ba2b2ad..ecb221cc8044 100644 --- a/lib/galaxy/managers/roles.py +++ b/lib/galaxy/managers/roles.py @@ -101,6 +101,59 @@ def create_role(self, trans: ProvidesUserContext, role_definition_model: RoleDef trans.sa_session.commit() return role + def delete(self, trans: ProvidesUserContext, role: model.Role) -> model.Role: + role.deleted = True + trans.sa_session.add(role) + with transaction(trans.sa_session): + trans.sa_session.commit() + return role + + def purge(self, trans: ProvidesUserContext, role: model.Role) -> model.Role: + # This method should only be called for a Role that has previously been deleted. + # Purging a deleted Role deletes all of the following from the database: + # - UserRoleAssociations where role_id == Role.id + # - DefaultUserPermissions where role_id == Role.id + # - DefaultHistoryPermissions where role_id == Role.id + # - GroupRoleAssociations where role_id == Role.id + # - DatasetPermissionss where role_id == Role.id + if not role.deleted: + raise galaxy.exceptions.RequestParameterInvalidException( + f"Role '{role.name}' has not been deleted, so it cannot be purged." + ) + # Delete UserRoleAssociations + for ura in role.users: + user = trans.sa_session.query(trans.app.model.User).get(ura.user_id) + # Delete DefaultUserPermissions for associated users + for dup in user.default_permissions: + if role == dup.role: + trans.sa_session.delete(dup) + # Delete DefaultHistoryPermissions for associated users + for history in user.histories: + for dhp in history.default_permissions: + if role == dhp.role: + trans.sa_session.delete(dhp) + trans.sa_session.delete(ura) + # Delete GroupRoleAssociations + for gra in role.groups: + trans.sa_session.delete(gra) + # Delete DatasetPermissionss + for dp in role.dataset_actions: + trans.sa_session.delete(dp) + with transaction(trans.sa_session): + trans.sa_session.commit() + return role + + def undelete(self, trans: ProvidesUserContext, role: model.Role) -> model.Role: + if not role.deleted: + raise galaxy.exceptions.RequestParameterInvalidException( + f"Role '{role.name}' has not been deleted, so it cannot be undeleted." + ) + role.deleted = False + trans.sa_session.add(role) + with transaction(trans.sa_session): + trans.sa_session.commit() + return role + def get_roles_by_ids(session: Session, role_ids): stmt = select(Role).where(Role.id.in_(role_ids)) diff --git a/lib/galaxy/webapps/galaxy/api/roles.py b/lib/galaxy/webapps/galaxy/api/roles.py index 92d6369285d0..53ef695a0aca 100644 --- a/lib/galaxy/webapps/galaxy/api/roles.py +++ b/lib/galaxy/webapps/galaxy/api/roles.py @@ -55,3 +55,21 @@ def create( ) -> RoleModelResponse: role = self.role_manager.create_role(trans, role_definition_model) return role_to_model(role) + + @router.delete("/api/roles/{id}", require_admin=True) + def delete(self, id: DecodedDatabaseIdField, trans: ProvidesUserContext = DependsOnTrans) -> RoleModelResponse: + role = self.role_manager.get(trans, id) + role = self.role_manager.delete(trans, role) + return role_to_model(role) + + @router.post("/api/roles/{id}/purge", require_admin=True) + def purge(self, id: DecodedDatabaseIdField, trans: ProvidesUserContext = DependsOnTrans) -> RoleModelResponse: + role = self.role_manager.get(trans, id) + role = self.role_manager.purge(trans, role) + return role_to_model(role) + + @router.post("/api/roles/{id}/undelete", require_admin=True) + def undelete(self, id: DecodedDatabaseIdField, trans: ProvidesUserContext = DependsOnTrans) -> RoleModelResponse: + role = self.role_manager.get(trans, id) + role = self.role_manager.undelete(trans, role) + return role_to_model(role) diff --git a/lib/galaxy/webapps/galaxy/controllers/admin.py b/lib/galaxy/webapps/galaxy/controllers/admin.py index ade0de78b7ca..f6068d87df13 100644 --- a/lib/galaxy/webapps/galaxy/controllers/admin.py +++ b/lib/galaxy/webapps/galaxy/controllers/admin.py @@ -159,27 +159,7 @@ def apply_query_filter(self, query, **kwargs): return query -class RoleListGrid(grids.Grid): - class NameColumn(grids.TextColumn): - def get_value(self, trans, grid, role): - return escape(role.name) - - class DescriptionColumn(grids.TextColumn): - def get_value(self, trans, grid, role): - if role.description: - return escape(role.description) - return "" - - class TypeColumn(grids.TextColumn): - def get_value(self, trans, grid, role): - return role.type - - class StatusColumn(grids.GridColumn): - def get_value(self, trans, grid, role): - if role.deleted: - return "deleted" - return "" - +class RoleListGrid(grids.GridData): class GroupsColumn(grids.GridColumn): def get_value(self, trans, grid, role): if role.groups: @@ -198,63 +178,50 @@ def get_value(self, trans, grid, role): model_class = model.Role default_sort_key = "name" columns = [ - NameColumn( - "Name", - key="name", - link=(lambda item: dict(action="form/manage_users_and_groups_for_role", id=item.id, webapp="galaxy")), - model_class=model.Role, - attach_popup=True, - filterable="advanced", - target="top", - ), - DescriptionColumn( - "Description", key="description", model_class=model.Role, attach_popup=False, filterable="advanced" - ), - TypeColumn("Type", key="type", model_class=model.Role, attach_popup=False, filterable="advanced"), - GroupsColumn("Groups", attach_popup=False), - UsersColumn("Users", attach_popup=False), - StatusColumn("Status", attach_popup=False), - # Columns that are valid for filtering but are not visible. - grids.DeletedColumn("Deleted", key="deleted", visible=False, filterable="advanced"), + grids.GridColumn("Name", key="name"), + grids.GridColumn("Description", key="description"), + grids.GridColumn("Type", key="type"), + GroupsColumn("Groups", key="groups"), + UsersColumn("Users", key="users"), + grids.GridColumn("Deleted", key="deleted", escape=False), + grids.GridColumn("Purged", key="purged", escape=False), grids.GridColumn("Last Updated", key="update_time"), ] - columns.append( - grids.MulticolFilterColumn( - "Search", - cols_to_filter=[columns[0], columns[1], columns[2]], - key="free-text-search", - visible=False, - filterable="standard", - ) - ) - global_actions = [grids.GridAction("Add new role", url_args=dict(action="form/create_role"))] - operations = [ - grids.GridOperation( - "Edit Name/Description", - condition=(lambda item: not item.deleted), - allow_multiple=False, - url_args=dict(action="form/rename_role"), - ), - grids.GridOperation( - "Edit Permissions", - condition=(lambda item: not item.deleted), - allow_multiple=False, - url_args=dict(action="form/manage_users_and_groups_for_role", webapp="galaxy"), - ), - grids.GridOperation("Delete", condition=(lambda item: not item.deleted), allow_multiple=True), - grids.GridOperation("Undelete", condition=(lambda item: item.deleted), allow_multiple=True), - grids.GridOperation("Purge", condition=(lambda item: item.deleted), allow_multiple=True), - ] - standard_filters = [ - grids.GridColumnFilter("Active", args=dict(deleted=False)), - grids.GridColumnFilter("Deleted", args=dict(deleted=True)), - grids.GridColumnFilter("All", args=dict(deleted="All")), - ] - num_rows_per_page = 50 - use_paging = True - def apply_query_filter(self, trans, query, **kwargs): - return query.filter(model.Role.type != model.Role.types.PRIVATE) + def apply_query_filter(self, query, **kwargs): + INDEX_SEARCH_FILTERS = { + "description": "description", + "name": "name", + "is": "is", + } + deleted = False + query = query.filter(self.model_class.type != self.model_class.types.PRIVATE) + search_query = kwargs.get("search") + if search_query: + parsed_search = parse_filters_structured(search_query, INDEX_SEARCH_FILTERS) + for term in parsed_search.terms: + if isinstance(term, FilteredTerm): + key = term.filter + q = term.text + if key == "name": + query = query.filter(text_column_filter(self.model_class.name, term)) + if key == "description": + query = query.filter(text_column_filter(self.model_class.description, term)) + elif key == "is": + if q == "deleted": + deleted = True + elif isinstance(term, RawTextTerm): + query = query.filter( + raw_text_column_filter( + [ + self.model_class.description, + self.model_class.name, + ], + term, + ) + ) + query = query.filter(self.model_class.deleted == (true() if deleted else false())) + return query class GroupListGrid(grids.Grid): @@ -854,23 +821,6 @@ def tool_versions_list(self, trans, **kwd): @web.json @web.require_admin def roles_list(self, trans, **kwargs): - message = kwargs.get("message") - status = kwargs.get("status") - if "operation" in kwargs: - id = kwargs.get("id", None) - if not id: - message, status = (f"Invalid role id ({str(id)}) received.", "error") - ids = util.listify(id) - operation = kwargs["operation"].lower().replace("+", " ") - if operation == "delete": - message, status = self._delete_role(trans, ids) - elif operation == "undelete": - message, status = self._undelete_role(trans, ids) - elif operation == "purge": - message, status = self._purge_role(trans, ids) - if message and status: - kwargs["message"] = util.sanitize_text(message) - kwargs["status"] = status return self.role_list_grid(trans, **kwargs) @web.legacy_expose_api @@ -1068,69 +1018,6 @@ def manage_users_and_groups_for_role(self, trans, payload=None, **kwd): "message": f"Role '{role.name}' has been updated with {len(in_users)} associated users and {len(in_groups)} associated groups." } - def _delete_role(self, trans, ids): - message = "Deleted %d roles: " % len(ids) - for role_id in ids: - role = get_role(trans, role_id) - role.deleted = True - trans.sa_session.add(role) - with transaction(trans.sa_session): - trans.sa_session.commit() - message += f" {role.name} " - return (message, "done") - - def _undelete_role(self, trans, ids): - count = 0 - undeleted_roles = "" - for role_id in ids: - role = get_role(trans, role_id) - if not role.deleted: - return (f"Role '{role.name}' has not been deleted, so it cannot be undeleted.", "error") - role.deleted = False - trans.sa_session.add(role) - with transaction(trans.sa_session): - trans.sa_session.commit() - count += 1 - undeleted_roles += f" {role.name}" - return ("Undeleted %d roles: %s" % (count, undeleted_roles), "done") - - def _purge_role(self, trans, ids): - # This method should only be called for a Role that has previously been deleted. - # Purging a deleted Role deletes all of the following from the database: - # - UserRoleAssociations where role_id == Role.id - # - DefaultUserPermissions where role_id == Role.id - # - DefaultHistoryPermissions where role_id == Role.id - # - GroupRoleAssociations where role_id == Role.id - # - DatasetPermissionss where role_id == Role.id - message = "Purged %d roles: " % len(ids) - for role_id in ids: - role = get_role(trans, role_id) - if not role.deleted: - return (f"Role '{role.name}' has not been deleted, so it cannot be purged.", "error") - # Delete UserRoleAssociations - for ura in role.users: - user = trans.sa_session.query(trans.app.model.User).get(ura.user_id) - # Delete DefaultUserPermissions for associated users - for dup in user.default_permissions: - if role == dup.role: - trans.sa_session.delete(dup) - # Delete DefaultHistoryPermissions for associated users - for history in user.histories: - for dhp in history.default_permissions: - if role == dhp.role: - trans.sa_session.delete(dhp) - trans.sa_session.delete(ura) - # Delete GroupRoleAssociations - for gra in role.groups: - trans.sa_session.delete(gra) - # Delete DatasetPermissionss - for dp in role.dataset_actions: - trans.sa_session.delete(dp) - with transaction(trans.sa_session): - trans.sa_session.commit() - message += f" {role.name} " - return (message, "done") - @web.legacy_expose_api @web.require_admin def groups_list(self, trans, **kwargs):