diff --git a/docs/en_US/images/add_role.png b/docs/en_US/images/add_role.png new file mode 100644 index 000000000..69739eb8e Binary files /dev/null and b/docs/en_US/images/add_role.png differ diff --git a/docs/en_US/images/permissions.png b/docs/en_US/images/permissions.png new file mode 100644 index 000000000..d1b6eb08b Binary files /dev/null and b/docs/en_US/images/permissions.png differ diff --git a/docs/en_US/images/roles.png b/docs/en_US/images/roles.png new file mode 100644 index 000000000..293a3d53c Binary files /dev/null and b/docs/en_US/images/roles.png differ diff --git a/docs/en_US/images/user.png b/docs/en_US/images/user.png deleted file mode 100644 index dcfe729d4..000000000 Binary files a/docs/en_US/images/user.png and /dev/null differ diff --git a/docs/en_US/images/users.png b/docs/en_US/images/users.png new file mode 100644 index 000000000..46021c610 Binary files /dev/null and b/docs/en_US/images/users.png differ diff --git a/docs/en_US/user_management.rst b/docs/en_US/user_management.rst index 792f41802..d34711a07 100644 --- a/docs/en_US/user_management.rst +++ b/docs/en_US/user_management.rst @@ -12,7 +12,7 @@ When you authenticate with pgAdmin, the server definitions associated with that login role are made available in the tree control. Users Tab -******************* +********* An administrative user can use the *Users* tab to: * manage pgAdmin users @@ -21,7 +21,7 @@ An administrative user can use the *Users* tab to: * deactivate user * unlock a locked user -.. image:: images/user.png +.. image:: images/users.png :alt: pgAdmin user management window :align: center @@ -78,6 +78,60 @@ users, but otherwise have the same capabilities as those with the *User* role. * Click the *Help* button (?) to access online help. +Roles Tab +********* +An administrative user can use the *Roles* tab to: + +* manage pgAdmin roles +* delete roles + +.. image:: images/roles.png + :alt: pgAdmin roles management window + :align: center + +Use the *Search* field to specify criteria and review a list of roles +that match the specified criteria. You can enter a value that matches +the following criteria types: *Role Name* or *Description*. + +To add a role, click the Add (+) button at the top left corner. It will open a +dialog where you can fill in details for the new role. + +.. image:: images/add_role.png + :alt: pgAdmin roles management window add new role + :align: center + +Provide information about the new pgAdmin role in the row: + +* Use the *Name* field to specify a unique name for the role. +* Use the *Description* field to provide a brief description of the role. + +To delete a role, click the trash icon to the left of the row and confirm deletion +in the *Delete role?* dialog. If the role is associated with any users or resources, +you may need to reassign those associations before deletion. + +Roles allow administrators to group privileges and assign them to users more efficiently. +This helps in managing permissions and access control within the pgAdmin client. + +* Click the *Refresh* button to get the latest roles list. +* Click the *Help* button (?) to access online help. + + +Permissions Tab +*************** +An administrative user can use the *Permissions* tab to manage pgAdmin permissions for +a role. + +.. image:: images/permissions.png + :alt: pgAdmin permissions management window + :align: center + +* Filter permissions using the *Search* field by entering names that match the list. +* Administrators can select permissions from the list of available permissions, and + choose to grant or revoke these permissions for specific roles. +* The permissions are applied to the selected role immediately. + + + Using 'setup.py' command line script #################################### @@ -108,10 +162,11 @@ email and password. role and active will be optional fields. /path/to/python /path/to/setup.py add-user user1@gmail.com password - # to specify a role, admin and non-admin users: + # to specify a role, either you can use --admin for Administrator role or provide the + # role using --role. If both are provided --admin will be used: /path/to/python /path/to/setup.py add-user user1@gmail.com password --admin - /path/to/python /path/to/setup.py add-user user1@gmail.com password --nonadmin + /path/to/python /path/to/setup.py add-user user1@gmail.com password --role Users # to specify user's status @@ -132,10 +187,11 @@ followed by email, password and authentication source. email, role and status wi /path/to/python /path/to/setup.py add-external-user ldapuser ldap --email user1@gmail.com - # to specify a role, admin and non-admin user: + # to specify a role, either you can use --admin for Administrator role or provide the + # role using --role. If both are provided --admin will be used: /path/to/python /path/to/setup.py add-external-user ldapuser ldap --admin - /path/to/python /path/to/setup.py add-external-user ldapuser ldap --nonadmin + /path/to/python /path/to/setup.py add-external-user ldapuser ldap --role Users # to specify user's status @@ -152,10 +208,11 @@ email address. password, role and active are updatable fields. /path/to/python /path/to/setup.py update-user user1@gmail.com --password new-password - # to specify a role, admin and non-admin user: + # to specify a role, either you can use --admin for Administrator role or provide the + # role using --role. If both are provided --admin will be used: - /path/to/python /path/to/setup.py update-user user1@gmail.com password --role --admin - /path/to/python /path/to/setup.py update-user user1@gmail.com password --role --nonadmin + /path/to/python /path/to/setup.py update-user user1@gmail.com password --admin + /path/to/python /path/to/setup.py update-user user1@gmail.com password --role Users # to specify user's status @@ -172,17 +229,18 @@ followed by username and auth source. email, password, role and active are updat # to change email address: - /path/to/python /path/to/setup.py update-external-user ldap ldapuser --email newemail@gmail.com + /path/to/python /path/to/setup.py update-external-user ldapuser --auth-source ldap --email newemail@gmail.com - # to specify a role, admin and non-admin user: + # to specify a role, either you can use --admin for Administrator role or provide the + # role using --role. If both are provided --admin will be used: - /path/to/python /path/to/setup.py update-user user1@gmail.com password --role --admin - /path/to/python /path/to/setup.py update-user user1@gmail.com password --role --nonadmin + /path/to/python /path/to/setup.py update-external-user user1@gmail.com password --role --admin + /path/to/python /path/to/setup.py update-external-user user1@gmail.com password --role --role Users # to change user's status - /path/to/python /path/to/setup.py update-user ldap ldapuser --active - /path/to/python /path/to/setup.py update-user ldap ldapuser --inactive + /path/to/python /path/to/setup.py update-user ldapuser --auth-source ldap --active + /path/to/python /path/to/setup.py update-user ldapuser --auth-source ldap --inactive Delete User *********** diff --git a/web/migrations/versions/1f0eddc8fc79_.py b/web/migrations/versions/1f0eddc8fc79_.py index 536dd6afc..bad79b21a 100644 --- a/web/migrations/versions/1f0eddc8fc79_.py +++ b/web/migrations/versions/1f0eddc8fc79_.py @@ -30,6 +30,20 @@ def upgrade(): sa.Column('db_res_type', sa.String(length=32), server_default=RESTRICTION_TYPE_DATABASES)) + # For adding custom role permissions + op.add_column('role', sa.Column('permissions', sa.Text())) + + # get metadata from current connection + meta = sa.MetaData() + # define table representation + meta.reflect(op.get_bind(), only=('role',)) + role_table = sa.Table('role', meta) + + from pgadmin.tools.user_management.PgAdminPermissions import AllPermissionTypes + op.execute( + role_table.update().where(role_table.c.name == 'User') + .values(permissions=",".join(AllPermissionTypes.list()))) + def downgrade(): # pgAdmin only upgrades, downgrade not implemented. diff --git a/web/pgadmin/__init__.py b/web/pgadmin/__init__.py index 88b368a97..fbea09111 100644 --- a/web/pgadmin/__init__.py +++ b/web/pgadmin/__init__.py @@ -349,6 +349,8 @@ def create_app(app_name=None): app.config['SECURITY_MSG_INVALID_PASSWORD'] = \ (gettext("Incorrect username or password."), "error") app.config['SECURITY_PASSWORD_LENGTH_MIN'] = config.PASSWORD_LENGTH_MIN + app.config['SECURITY_MSG_UNAUTHORIZED'] = \ + (gettext("Unauthorised access, permission denied."), "error") # Create database connection object and mailer db.init_app(app) diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index 63ae49fd5..ed1579702 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -13,7 +13,7 @@ import pgadmin.browser.server_groups as sg from flask import render_template, request, make_response, jsonify, \ current_app, url_for, session from flask_babel import gettext -from flask_security import current_user +from flask_security import current_user, permissions_required from pgadmin.user_login_check import pga_login_required from psycopg.conninfo import make_conninfo, conninfo_to_dict @@ -24,6 +24,7 @@ from pgadmin.utils.ajax import make_json_response, bad_request, forbidden, \ from pgadmin.utils.crypto import encrypt, decrypt, pqencryptpassword from pgadmin.utils.menu import MenuItem from pgadmin.tools.sqleditor.utils.query_history import QueryHistory +from pgadmin.tools.user_management.PgAdminPermissions import AllPermissionTypes import config from config import PG_DEFAULT_DRIVER @@ -1081,6 +1082,7 @@ class ServerNode(PGChildNodeView): display_conn_string = make_conninfo(**con_info_ord) return display_conn_string + @permissions_required(AllPermissionTypes.object_register_server) @pga_login_required def create(self, gid): """Add a server node to the settings database""" diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/static/js/schema.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/static/js/schema.js index 94dc5f056..6229be58d 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/static/js/schema.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/static/js/schema.js @@ -10,6 +10,7 @@ import PGSchema from './schema.ui'; import { getNodePrivilegeRoleSchema } from '../../../../static/js/privilege.ui'; import { getNodeListByName } from '../../../../../../static/js/node_ajax'; +import { AllPermissionTypes } from '../../../../../../static/js/constants'; define('pgadmin.node.schema', [ 'sources/gettext', 'sources/url_for', @@ -64,7 +65,8 @@ define('pgadmin.node.schema', [ },{ name: 'generate_erd', node: 'schema', module: this, applies: ['object', 'context'], callback: 'generate_erd', - priority: 5, label: gettext('ERD For Schema') + priority: 5, label: gettext('ERD For Schema'), + permission: AllPermissionTypes.TOOLS_ERD_TOOL, }]); }, can_create_schema: function(node) { diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/table.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/table.js index 2cb357fe0..0ccb0db12 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/table.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/table.js @@ -9,6 +9,7 @@ import { getNodeTableSchema } from './table.ui'; import _ from 'lodash'; import getApiInstance from '../../../../../../../../static/js/api_instance'; +import { AllPermissionTypes } from '../../../../../../../static/js/constants'; define('pgadmin.node.table', [ 'pgadmin.tables.js/enable_disable_triggers', @@ -127,7 +128,8 @@ define('pgadmin.node.table', [ priority: 5, label: gettext('ERD For Table'), enable: (_, item) => { return !('catalog' in pgAdmin.Browser.tree.getTreeNodeHierarchy(item)); - } + }, + permission: AllPermissionTypes.TOOLS_ERD_TOOL, } ]); pgBrowser.Events.on( diff --git a/web/pgadmin/browser/server_groups/servers/databases/static/js/database.js b/web/pgadmin/browser/server_groups/servers/databases/static/js/database.js index b6bf851e5..35d3378dc 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/static/js/database.js +++ b/web/pgadmin/browser/server_groups/servers/databases/static/js/database.js @@ -14,6 +14,7 @@ import DatabaseSchema from './database.ui'; import { showServerPassword } from '../../../../../../static/js/Dialogs/index'; import _ from 'lodash'; import getApiInstance, { parseApiError } from '../../../../../../static/js/api_instance'; +import { AllPermissionTypes } from '../../../../../static/js/constants'; define('pgadmin.node.database', [ 'sources/gettext', 'sources/url_for', @@ -122,7 +123,8 @@ define('pgadmin.node.database', [ priority: 5, label: gettext('ERD For Database'), enable: (node) => { return node.allowConn; - } + }, + permission: AllPermissionTypes.TOOLS_ERD_TOOL, }]); _.bindAll(this, 'connection_lost'); diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.js b/web/pgadmin/browser/server_groups/servers/static/js/server.js index 573bc7e25..fc4b25c16 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.js @@ -12,6 +12,7 @@ import ServerSchema from './server.ui'; import { showServerPassword, showChangeServerPassword, showNamedRestorePoint } from '../../../../../static/js/Dialogs/index'; import _ from 'lodash'; import getApiInstance, { parseApiError } from '../../../../../static/js/api_instance'; +import { AllPermissionTypes } from '../../../../static/js/constants'; define('pgadmin.node.server', [ 'sources/gettext', 'sources/url_for', @@ -81,7 +82,7 @@ define('pgadmin.node.server', [ name: 'create_server_on_sg', node: 'server_group', module: this, applies: ['object', 'context'], callback: 'show_obj_properties', category: 'register', priority: 1, label: gettext('Server...'), - data: {action: 'create'}, enable: 'canCreate', + data: {action: 'create'}, enable: 'canCreate', permission: AllPermissionTypes.OBJECT_REGISTER_SERVER },{ name: 'disconnect_all_servers', node: 'server_group', module: this, applies: ['object','context'], callback: 'disconnect_all_servers', @@ -91,7 +92,7 @@ define('pgadmin.node.server', [ name: 'create_server', node: 'server', module: this, applies: ['object', 'context'], callback: 'show_obj_properties', category: 'register', priority: 3, label: gettext('Server...'), - data: {action: 'create'}, enable: 'canCreate', + data: {action: 'create'}, enable: 'canCreate', permission: AllPermissionTypes.OBJECT_REGISTER_SERVER },{ name: 'connect_server', node: 'server', module: this, applies: ['object', 'context'], callback: 'connect_server', @@ -167,7 +168,7 @@ define('pgadmin.node.server', [ name: 'copy_server', node: 'server', module: this, applies: ['object', 'context'], callback: 'show_obj_properties', label: gettext('Copy Server...'), data: {action: 'copy'}, - priority: 4, + priority: 4, permission: AllPermissionTypes.OBJECT_REGISTER_SERVER, }]); _.bindAll(this, 'connection_lost'); diff --git a/web/pgadmin/browser/static/js/MainMenuFactory.js b/web/pgadmin/browser/static/js/MainMenuFactory.js index 83e0d9aa8..54555809b 100644 --- a/web/pgadmin/browser/static/js/MainMenuFactory.js +++ b/web/pgadmin/browser/static/js/MainMenuFactory.js @@ -11,6 +11,7 @@ import pgAdmin from 'sources/pgadmin'; import Menu, { MenuItem } from '../../../static/js/helpers/Menu'; import getApiInstance from '../../../static/js/api_instance'; import url_for from 'sources/url_for'; +import withCheckPermission from './withCheckPermission'; const MAIN_MENUS = [ { label: gettext('File'), name: 'file', id: 'mnu_file', index: 0, addSeprator: true, hasDynamicMenuItems: false }, @@ -71,7 +72,7 @@ export default class MainMenuFactory { } static createMenuItem(options) { - return new MenuItem({...options, callback: () => { + const callback = () => { // Some callbacks registered in 'callbacks' check and call specifiec callback function if (options.module && 'callbacks' in options.module && options.module.callbacks[options.callback]) { options.module.callbacks[options.callback].apply(options.module, [options.data, pgAdmin.Browser.tree?.selected()]); @@ -89,7 +90,8 @@ export default class MainMenuFactory { pgAdmin.Browser.notifier.error(gettext('Error in opening window')); }); } - }}, (menu, item)=> { + }; + return new MenuItem({...options, callback: withCheckPermission(options, callback)}, (menu, item)=> { pgAdmin.Browser.Events.trigger('pgadmin:enable-disable-menu-items', menu, item); window.electronUI?.enableDisableMenuItems(menu?.serialize(), item?.serialize()); }); diff --git a/web/pgadmin/browser/static/js/browser.js b/web/pgadmin/browser/static/js/browser.js index 0f51bf741..f6f1d6626 100644 --- a/web/pgadmin/browser/static/js/browser.js +++ b/web/pgadmin/browser/static/js/browser.js @@ -391,6 +391,7 @@ define('pgadmin.browser', [ checked: _m.checked, below: _m.below, applies: _m.applies, + permission: _m.permission, }; }; diff --git a/web/pgadmin/browser/static/js/collection.js b/web/pgadmin/browser/static/js/collection.js index a4935ffaa..caff5c3a4 100644 --- a/web/pgadmin/browser/static/js/collection.js +++ b/web/pgadmin/browser/static/js/collection.js @@ -7,6 +7,7 @@ // ////////////////////////////////////////////////////////////// import _ from 'lodash'; +import { AllPermissionTypes } from './constants'; define([ 'sources/gettext', 'sources/pgadmin', @@ -53,6 +54,7 @@ define([ name: 'show_query_tool', node: this.type, module: this, applies: ['context'], callback: 'show_query_tool', priority: 998, label: gettext('Query Tool'), + permission: AllPermissionTypes.TOOLS_QUERY_TOOL, }]); // show search objects same as query tool @@ -60,6 +62,7 @@ define([ name: 'search_objects', node: this.type, module: this, applies: ['context'], callback: 'show_search_objects', priority: 997, label: gettext('Search Objects...'), + permission: AllPermissionTypes.TOOLS_SEARCH_OBJECTS, }]); // show psql tool same as query tool. @@ -68,6 +71,7 @@ define([ name: 'show_psql_tool', node: this.type, module: this, applies: ['context'], callback: 'show_psql_tool', priority: 998, label: gettext('PSQL Tool'), + permission: AllPermissionTypes.TOOLS_PSQL_TOOL, }]); } } diff --git a/web/pgadmin/browser/static/js/constants.js b/web/pgadmin/browser/static/js/constants.js index 882e85e61..944752552 100644 --- a/web/pgadmin/browser/static/js/constants.js +++ b/web/pgadmin/browser/static/js/constants.js @@ -116,3 +116,21 @@ export const WEEKDAYS = [ ]; export const PGAGENT_MONTHDAYS = [...MONTHDAYS].concat([{label: gettext('Last day'), value: 'Last Day'}]); + +export const AllPermissionTypes = { + OBJECT_REGISTER_SERVER: 'object_register_server', + TOOLS_ERD_TOOL: 'tools_erd_tool', + TOOLS_QUERY_TOOL: 'tools_query_tool', + TOOLS_DEBUGGER: 'tools_debugger', + TOOLS_PSQL_TOOL: 'tools_psql_tool', + TOOLS_BACKUP: 'tools_backup', + TOOLS_RESTORE: 'tools_restore', + TOOLS_IMPORT_EXPORT_DATA: 'tools_import_export_data', + TOOLS_IMPORT_EXPORT_SERVERS: 'tools_import_export_servers', + TOOLS_SEARCH_OBJECTS: 'tools_search_objects', + TOOLS_MAINTENANCE: 'tools_maintenance', + TOOLS_SCHEMA_DIFF: 'tools_schema_diff', + TOOLS_GRANT_WIZARD: 'tools_grant_wizard', + STORAGE_ADD_FOLDER: 'storage_add_folder', + STORAGE_REMOVE_FOLDER: 'storage_remove_folder' +}; diff --git a/web/pgadmin/browser/static/js/node.js b/web/pgadmin/browser/static/js/node.js index 8dfdd362f..8a35f3f66 100644 --- a/web/pgadmin/browser/static/js/node.js +++ b/web/pgadmin/browser/static/js/node.js @@ -9,7 +9,7 @@ import _ from 'lodash'; import getApiInstance from '../../../static/js/api_instance'; -import { BROWSER_PANELS } from './constants'; +import { AllPermissionTypes, BROWSER_PANELS } from './constants'; import React from 'react'; import ObjectNodeProperties from '../../../misc/properties/ObjectNodeProperties'; import ErrorBoundary from '../../../static/js/helpers/ErrorBoundary'; @@ -161,6 +161,7 @@ define('pgadmin.browser.node', [ function() { return !!(self.canDrop(...arguments)); } : (!!self.canDrop), + permission: self.type == 'server' ? 'object_regiter_server' : undefined, }]); if (self.canDropCascade) { @@ -202,6 +203,7 @@ define('pgadmin.browser.node', [ priority: 998, label: gettext('Query Tool'), enable: enable, + permission: AllPermissionTypes.TOOLS_QUERY_TOOL, }]); // show search objects same as query tool @@ -210,6 +212,7 @@ define('pgadmin.browser.node', [ applies: ['context'], callback: 'show_search_objects', priority: 997, label: gettext('Search Objects...'), icon: 'fa fa-search', enable: enable, + permission: AllPermissionTypes.TOOLS_SEARCH_OBJECTS, }]); if(pgAdmin['enable_psql']) { @@ -218,6 +221,7 @@ define('pgadmin.browser.node', [ name: 'show_psql_tool', node: this.type, module: this, applies: ['context'], callback: 'show_psql_tool', priority: 998, label: gettext('PSQL Tool'), + permission: AllPermissionTypes.TOOLS_PSQL_TOOL, }]); } } @@ -247,6 +251,7 @@ define('pgadmin.browser.node', [ data_disabled: gettext('The selected tree node does not support this option.'), }, enable: self.check_user_permission, + permission: AllPermissionTypes.TOOLS_QUERY_TOOL, }]); }); } diff --git a/web/pgadmin/browser/static/js/withCheckPermission.js b/web/pgadmin/browser/static/js/withCheckPermission.js new file mode 100644 index 000000000..7f118ef8c --- /dev/null +++ b/web/pgadmin/browser/static/js/withCheckPermission.js @@ -0,0 +1,27 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2025, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import pgAdmin from 'sources/pgadmin'; +import current_user from 'pgadmin.user_management.current_user'; +import gettext from 'sources/gettext'; + +export default function withCheckPermission(options, callback) { + // Check if the user has permission to access the menu item + return ()=>{ + // if the permission are not provided then no restrictions. + if(!options.permission || (options.permission && current_user.permissions?.includes(options.permission))) { + callback(); + } else { + pgAdmin.Browser.notifier.alert( + gettext('Permission Denied'), + gettext('You don’t have the necessary permissions to access this feature. Please contact your administrator for assistance') + ); + } + }; +} diff --git a/web/pgadmin/dashboard/static/js/components/SectionContainer.jsx b/web/pgadmin/dashboard/static/js/components/SectionContainer.jsx index dfffde7e1..4c4bdb717 100644 --- a/web/pgadmin/dashboard/static/js/components/SectionContainer.jsx +++ b/web/pgadmin/dashboard/static/js/components/SectionContainer.jsx @@ -29,6 +29,7 @@ const StyledBox = styled(Box)(({theme}) => ({ '& .SectionContainer-cardTitle': { padding: '0.25rem 0.5rem', fontWeight: 'bold', + width: '100%', } }, })); @@ -50,7 +51,7 @@ export default function SectionContainer({title, titleExtras, children, style}) } SectionContainer.propTypes = { - title: PropTypes.string.isRequired, + title: PropTypes.any.isRequired, titleExtras: PropTypes.node, children: PropTypes.node.isRequired, style: PropTypes.object, diff --git a/web/pgadmin/misc/file_manager/__init__.py b/web/pgadmin/misc/file_manager/__init__.py index 022f6d0cc..10d11f015 100644 --- a/web/pgadmin/misc/file_manager/__init__.py +++ b/web/pgadmin/misc/file_manager/__init__.py @@ -35,6 +35,7 @@ from pgadmin.utils.preferences import Preferences from pgadmin.utils.constants import PREF_LABEL_OPTIONS, MIMETYPE_APP_JS, \ MY_STORAGE from pgadmin.settings.utils import get_file_type_setting +from pgadmin.tools.user_management.PgAdminPermissions import AllPermissionTypes # Checks if platform is Windows if _platform == "win32": @@ -345,6 +346,20 @@ class Filemanager(): return last_dir + @staticmethod + def check_capability_permission(capability): + """ + Check if the user has permission for the capability + """ + if capability == 'create': + return current_user.has_permission( + AllPermissionTypes.storage_add_folder) + elif capability == 'delete': + return current_user.has_permission( + AllPermissionTypes.storage_remove_folder) + + return True + @staticmethod def create_new_transaction(params): """ @@ -366,16 +381,20 @@ class Filemanager(): show_volumes = isinstance(storage_dir, list) or not storage_dir supp_types = allow_upload_files = params.get('supported_types', []) + allow_folder_create = ['create'] if \ + Filemanager.check_capability_permission('create') else [] + allow_folder_delete = ['delete'] if \ + Filemanager.check_capability_permission('delete') else [] # tuples with (capabilities, files_only, folders_only, title) capability_map = { 'select_file': ( - ['select_file', 'rename', 'upload', 'delete'], + ['select_file', 'rename', 'upload'] + allow_folder_delete, True, False, gettext("Select File") ), 'select_folder': ( - ['select_folder', 'rename', 'create'], + ['select_folder', 'rename'] + allow_folder_create, False, True, gettext("Select Folder") @@ -387,14 +406,14 @@ class Filemanager(): gettext("Select File") ), 'create_file': ( - ['select_file', 'rename', 'create'], + ['select_file', 'rename'] + allow_folder_create, True, False, gettext("Create File") ), 'storage_dialog': ( - ['select_folder', 'select_file', 'download', - 'rename', 'delete', 'upload', 'create'], + ['select_folder', 'select_file', 'download', 'rename', + 'upload'] + allow_folder_delete + allow_folder_create, True, False, gettext("Storage Manager") @@ -767,7 +786,9 @@ class Filemanager(): stored in the session """ trans_data = Filemanager.get_trasaction_selection(self.trans_id) - return False if capability not in trans_data['capabilities'] else True + # capability + return False if capability not in trans_data['capabilities'] \ + else Filemanager.check_capability_permission(capability) def getfolder(self, path=None, file_type="", show_hidden=False): """ diff --git a/web/pgadmin/misc/file_manager/static/js/components/FileManager.jsx b/web/pgadmin/misc/file_manager/static/js/components/FileManager.jsx index 9e341209f..4f521882b 100644 --- a/web/pgadmin/misc/file_manager/static/js/components/FileManager.jsx +++ b/web/pgadmin/misc/file_manager/static/js/components/FileManager.jsx @@ -873,4 +873,4 @@ FileManager.propTypes = { onCancel: PropTypes.func, sharedStorages: PropTypes.array, restrictedSharedStorage: PropTypes.array, -}; \ No newline at end of file +}; diff --git a/web/pgadmin/misc/workspaces/static/js/WorkspaceToolbar.jsx b/web/pgadmin/misc/workspaces/static/js/WorkspaceToolbar.jsx index 76022262c..9a094460b 100644 --- a/web/pgadmin/misc/workspaces/static/js/WorkspaceToolbar.jsx +++ b/web/pgadmin/misc/workspaces/static/js/WorkspaceToolbar.jsx @@ -17,10 +17,11 @@ import AccountTreeRoundedIcon from '@mui/icons-material/AccountTreeRounded'; import { PgIconButton } from '../../../../static/js/components/Buttons'; import PropTypes from 'prop-types'; import { styled } from '@mui/material/styles'; -import { WORKSPACES } from '../../../../browser/static/js/constants'; +import { AllPermissionTypes, WORKSPACES } from '../../../../browser/static/js/constants'; import { useWorkspace } from './WorkspaceProvider'; import { LAYOUT_EVENTS } from '../../../../static/js/helpers/Layout'; import gettext from 'sources/gettext'; +import withCheckPermission from '../../../../browser/static/js/withCheckPermission'; const StyledWorkspaceButton = styled(PgIconButton)(({theme}) => ({ '&.Buttons-iconButtonDefault': { @@ -44,7 +45,7 @@ const StyledWorkspaceButton = styled(PgIconButton)(({theme}) => ({ }, })); -function WorkspaceButton({menuItem, value, ...props}) { +function WorkspaceButton({menuItem, value, options, ...props}) { const {currentWorkspace, hasOpenTabs, getLayoutObj, onWorkspaceDisabled, changeWorkspace} = useWorkspace(); const active = value == currentWorkspace; const [disabled, setDisabled] = useState(); @@ -75,12 +76,16 @@ function WorkspaceButton({menuItem, value, ...props}) { }, [disabled]); return ( - { if(menuItem) { menuItem?.callback(); } else { - changeWorkspace(value); + // Check permission and call. + withCheckPermission(options, () => { + changeWorkspace(value); + })(); } }} disabled={disabled} @@ -91,7 +96,8 @@ WorkspaceButton.propTypes = { menuItem: PropTypes.object, active: PropTypes.bool, changeWorkspace: PropTypes.func, - value: PropTypes.string + value: PropTypes.string, + options: PropTypes.object, }; const Root = styled('div')(({theme}) => ({ @@ -124,9 +130,9 @@ export default function WorkspaceToolbar() { return ( } value={WORKSPACES.DEFAULT} title={gettext('Default Workspace')} tooltipPlacement="right" /> - } value={WORKSPACES.QUERY_TOOL} title={gettext('Query Tool Workspace')} tooltipPlacement="right" /> - {pgAdmin['enable_psql'] && } value={WORKSPACES.PSQL_TOOL} title={gettext('PSQL Tool Workspace')} tooltipPlacement="right" />} - } value={WORKSPACES.SCHEMA_DIFF_TOOL} title={gettext('Schema Diff Workspace')} tooltipPlacement="right" /> + } value={WORKSPACES.QUERY_TOOL} title={gettext('Query Tool Workspace')} tooltipPlacement="right" options={{permission: AllPermissionTypes.TOOLS_QUERY_TOOL}} /> + {pgAdmin['enable_psql'] && } value={WORKSPACES.PSQL_TOOL} title={gettext('PSQL Tool Workspace')} tooltipPlacement="right" options={{permission: AllPermissionTypes.TOOLS_PSQL_TOOL}} />} + } value={WORKSPACES.SCHEMA_DIFF_TOOL} title={gettext('Schema Diff Workspace')} tooltipPlacement="right" options={{permission: AllPermissionTypes.TOOLS_SCHEMA_DIFF}} /> } menuItem={menus['settings']} title={gettext('Preferences')} tooltipPlacement="right" /> diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py index 057a2ab79..af1251f12 100644 --- a/web/pgadmin/model/__init__.py +++ b/web/pgadmin/model/__init__.py @@ -59,6 +59,29 @@ roles_users = db.Table( ) +class PgAdminDbArrayString(types.TypeDecorator): + cache_ok = True + impl = types.String + + def process_bind_param(self, value, dialect): + try: + if len(value) == 0: + return None + + return ",".join(value) + except Exception as _: + return None + + def process_result_value(self, value, dialect): + try: + if value == '': + return [] + + return value.split(',') + except Exception as _: + return [] + + class PgAdminDbBinaryString(types.TypeDecorator): """ To make binary string storing compatible with both @@ -92,6 +115,27 @@ class Role(db.Model, RoleMixin): id = db.Column(db.Integer(), primary_key=True) name = db.Column(db.String(128), unique=True, nullable=False) description = db.Column(db.String(256), nullable=False) + # permissions needs to be an array, use custom type to support + # both SQLite and PostgreSQL + permissions = db.Column(PgAdminDbArrayString()) + + def get_permissions(self): + from pgadmin.tools.user_management.PgAdminPermissions \ + import AllPermissionTypes + if self.name == 'Administrator': + return AllPermissionTypes.list() + + return super().get_permissions() + + +# We override the default UserMixin to change behaviour of has_permission +# Administrator has all permissions +class CustomUserMixin(UserMixin): + def has_permission(self, permission: str) -> bool: + if 'Administrator' in self.roles: + return True + + return super().has_permission(permission) class User(db.Model, UserMixin): diff --git a/web/pgadmin/static/js/Theme/dark.js b/web/pgadmin/static/js/Theme/dark.js index 507e6835b..975ed6e4b 100644 --- a/web/pgadmin/static/js/Theme/dark.js +++ b/web/pgadmin/static/js/Theme/dark.js @@ -65,15 +65,16 @@ export default function(basicSettings) { primary: '#d4d4d4', muted: '#8A8A8A', }, - checkbox: { - disabled: '#6b6b6b' - }, background: { paper: '#1e1e1e', default: '#1e1e1e', } }, custom: { + checkbox: { + borderColor: '#4a4a4a', + disabled: '#6b6b6b' + }, icon: { main: '#6b6b6b', contrastText: '#fff', diff --git a/web/pgadmin/static/js/Theme/high_contrast.js b/web/pgadmin/static/js/Theme/high_contrast.js index 2042583c6..b5d53855a 100644 --- a/web/pgadmin/static/js/Theme/high_contrast.js +++ b/web/pgadmin/static/js/Theme/high_contrast.js @@ -63,15 +63,16 @@ export default function(basicSettings) { primary: '#fff', muted: '#8b9cac', }, - checkbox: { - disabled: '#6b6b6b' - }, background: { paper: '#010B15', default: '#010B15', }, }, custom: { + checkbox: { + borderColor: '#A6B7C8', + disabled: '#6b6b6b' + }, icon: { main: '#010B15', contrastText: '#fff', diff --git a/web/pgadmin/static/js/Theme/index.jsx b/web/pgadmin/static/js/Theme/index.jsx index 3d3454dd0..146864cce 100644 --- a/web/pgadmin/static/js/Theme/index.jsx +++ b/web/pgadmin/static/js/Theme/index.jsx @@ -644,12 +644,12 @@ function getFinalTheme(baseTheme) { styleOverrides: { root: { padding: '0px', - color: baseTheme.otherVars.inputBorderColor, + color: baseTheme.custom.checkbox.borderColor, }, colorPrimary: { '&.Mui-disabled': { - color: baseTheme.palette.checkbox.disabled + color: baseTheme.custom.checkbox.disabled } } } @@ -658,12 +658,12 @@ function getFinalTheme(baseTheme) { styleOverrides: { root: { padding: '0px', - color: baseTheme.otherVars.inputBorderColor, + color: baseTheme.custom.checkbox.borderColor, }, colorPrimary: { '&.Mui-disabled': { - color: baseTheme.palette.checkbox.disabled + color: baseTheme.custom.checkbox.disabled } } } diff --git a/web/pgadmin/static/js/Theme/light.js b/web/pgadmin/static/js/Theme/light.js index 504c28efe..875793ea8 100644 --- a/web/pgadmin/static/js/Theme/light.js +++ b/web/pgadmin/static/js/Theme/light.js @@ -63,15 +63,16 @@ export default function(basicSettings) { primary: '#222', muted: '#646B82', }, - checkbox: { - disabled: '#ebeef3' - }, background: { paper: '#fff', default: '#fff', }, }, custom: { + checkbox: { + borderColor: '#bac1cd', + disabled: '#ebeef3' + }, icon: { main: '#fff', contrastText: '#222', diff --git a/web/pgadmin/static/js/components/FormComponents.jsx b/web/pgadmin/static/js/components/FormComponents.jsx index 752287428..325043bd1 100644 --- a/web/pgadmin/static/js/components/FormComponents.jsx +++ b/web/pgadmin/static/js/components/FormComponents.jsx @@ -1280,7 +1280,8 @@ const StyledNotifierMessageBox = styled(Box)(({theme}) => ({ '& .FormFooter-message': { color: theme.palette.text.primary, marginLeft: theme.spacing(0.5), - whiteSpace: 'pre-line' + whiteSpace: 'pre-line', + userSelect: 'text' }, '& .FormFooter-messageCenter': { color: theme.palette.text.primary, diff --git a/web/pgadmin/static/js/helpers/Menu.js b/web/pgadmin/static/js/helpers/Menu.js index 7fd7413ff..6f5a57a29 100644 --- a/web/pgadmin/static/js/helpers/Menu.js +++ b/web/pgadmin/static/js/helpers/Menu.js @@ -122,6 +122,7 @@ export class MenuItem { 'name', 'label', 'priority', 'module', 'callback', 'data', 'enable', 'category', 'target', 'url', 'node', 'single', 'checked', 'below', 'menu_items', 'is_checkbox', 'action', 'applies', 'is_native_only', 'type', + 'permission', ]; let defaults = { url: '#', @@ -169,16 +170,12 @@ export class MenuItem { return this.menu_items; } - contextMenuCallback(self) { - self.callback(); - } - getContextItem(label, is_disabled, sub_ctx_item) { let self = this; return { name: label, disabled: is_disabled, - callback: () => { this.contextMenuCallback(self); }, + callback: self.callback.bind(self), ...(sub_ctx_item && Object.keys(sub_ctx_item).length > 0) && { items: sub_ctx_item } }; } diff --git a/web/pgadmin/tools/backup/__init__.py b/web/pgadmin/tools/backup/__init__.py index 571eee71d..bf19df86e 100644 --- a/web/pgadmin/tools/backup/__init__.py +++ b/web/pgadmin/tools/backup/__init__.py @@ -25,10 +25,11 @@ from config import PG_DEFAULT_DRIVER # This unused import is required as API test cases will fail if we remove it, # Have to identify the cause and then remove it. from pgadmin.model import Server, SharedServer -from flask_security import current_user +from flask_security import current_user, permissions_required from pgadmin.misc.bgprocess import escape_dquotes_process_arg from pgadmin.utils.constants import MIMETYPE_APP_JS, SERVER_NOT_FOUND from pgadmin.tools.grant_wizard import get_data +from pgadmin.tools.user_management.PgAdminPermissions import AllPermissionTypes # set template path for sql scripts MODULE_NAME = 'backup' @@ -182,19 +183,6 @@ def index(): return bad_request(errormsg=gettext("This URL cannot be called directly.")) -@blueprint.route("/backup.js") -@pga_login_required -def script(): - """render own javascript""" - return Response( - response=render_template( - "backup/js/backup.js", _=_ - ), - status=200, - mimetype=MIMETYPE_APP_JS - ) - - def _get_args_params_values(data, conn, backup_obj_type, backup_file, server, manager): """ @@ -391,6 +379,7 @@ def _get_args_params_values(data, conn, backup_obj_type, backup_file, server, @blueprint.route( '/job//object', methods=['POST'], endpoint='create_object_job' ) +@permissions_required(AllPermissionTypes.tools_backup) @pga_login_required def create_backup_objects_job(sid): """ diff --git a/web/pgadmin/tools/backup/static/js/backup.js b/web/pgadmin/tools/backup/static/js/backup.js index ff1d41088..c50114dd5 100644 --- a/web/pgadmin/tools/backup/static/js/backup.js +++ b/web/pgadmin/tools/backup/static/js/backup.js @@ -12,6 +12,7 @@ import BackupGlobalSchema, {getMiscellaneousSchema as getMiscellaneousGlobalSche import getApiInstance from 'sources/api_instance'; import {retrieveAncestorOfTypeServer} from 'sources/tree/tree_utils'; import pgAdmin from 'sources/pgadmin'; +import { AllPermissionTypes } from '../../../../browser/static/js/constants'; // Backup dialog define([ @@ -66,6 +67,7 @@ define([ data: { data_disabled: gettext('Please select any server from the object explorer to take Backup of global objects.'), }, + permission: AllPermissionTypes.TOOLS_BACKUP, }, { name: 'backup_server', module: this, @@ -78,6 +80,7 @@ define([ data: { data_disabled: gettext('Please select any server from the object explorer to take Server Backup.'), }, + permission: AllPermissionTypes.TOOLS_BACKUP, }, { name: 'backup_global_ctx', module: this, @@ -91,6 +94,7 @@ define([ data: { data_disabled: gettext('Please select any database or schema or table from the object explorer to take Backup.'), }, + permission: AllPermissionTypes.TOOLS_BACKUP, }, { name: 'backup_server_ctx', module: this, @@ -104,6 +108,7 @@ define([ data: { data_disabled: gettext('Please select any server from the object explorer to take Server Backup.'), }, + permission: AllPermissionTypes.TOOLS_BACKUP, }, { name: 'backup_object', module: this, @@ -118,6 +123,7 @@ define([ data: { data_disabled: gettext('Please select any database or schema or table from the object explorer to take Backup.'), }, + permission: AllPermissionTypes.TOOLS_BACKUP, }]; for (let node_val of menuUtils.backupSupportedNodes) { diff --git a/web/pgadmin/tools/debugger/__init__.py b/web/pgadmin/tools/debugger/__init__.py index ada149231..49eed7caf 100644 --- a/web/pgadmin/tools/debugger/__init__.py +++ b/web/pgadmin/tools/debugger/__init__.py @@ -16,6 +16,7 @@ import copy from flask import render_template, request, current_app from flask_babel import gettext +from flask_security import permissions_required from pgadmin.user_login_check import pga_login_required from werkzeug.user_agent import UserAgent @@ -34,6 +35,7 @@ from pgadmin.browser.server_groups.servers.databases.extensions.utils \ import get_extension_details from pgadmin.utils.constants import PREF_LABEL_KEYBOARD_SHORTCUTS, \ SERVER_CONNECTION_CLOSED +from pgadmin.tools.user_management.PgAdminPermissions import AllPermissionTypes from pgadmin.preferences import preferences MODULE_NAME = 'debugger' @@ -375,6 +377,7 @@ def check_node_type(node_type, fid, trid, conn, ppas_server, '/init//////', methods=['GET'], endpoint='init_for_trigger' ) +@permissions_required(AllPermissionTypes.tools_debugger) @pga_login_required def init_function(node_type, sid, did, scid, fid, trid=None): """ diff --git a/web/pgadmin/tools/debugger/static/js/DebuggerModule.js b/web/pgadmin/tools/debugger/static/js/DebuggerModule.js index 5145e080c..d298c4eb8 100644 --- a/web/pgadmin/tools/debugger/static/js/DebuggerModule.js +++ b/web/pgadmin/tools/debugger/static/js/DebuggerModule.js @@ -24,7 +24,7 @@ import FunctionArguments from './debugger_ui'; import ModalProvider from '../../../../static/js/helpers/ModalProvider'; import DebuggerComponent from './components/DebuggerComponent'; import Theme from '../../../../static/js/Theme'; -import { BROWSER_PANELS } from '../../../../browser/static/js/constants'; +import { AllPermissionTypes, BROWSER_PANELS } from '../../../../browser/static/js/constants'; import { NotifierProvider } from '../../../../static/js/helpers/Notifier'; import usePreferences, { listenPreferenceBroadcast } from '../../../../preferences/static/js/store'; import pgAdmin from 'sources/pgadmin'; @@ -66,6 +66,7 @@ export default class DebuggerModule { object: 'function', }, enable: 'canDebug', + permission: AllPermissionTypes.TOOLS_DEBUGGER, }, { name: 'global_debugger', node: 'function', @@ -80,6 +81,7 @@ export default class DebuggerModule { debug_type: 'indirect', }, enable: 'canDebug', + permission: AllPermissionTypes.TOOLS_DEBUGGER, }, { name: 'procedure_direct_debugger', node: 'procedure', @@ -93,6 +95,7 @@ export default class DebuggerModule { object: 'procedure', }, enable: 'canDebug', + permission: AllPermissionTypes.TOOLS_DEBUGGER, }, { name: 'procedure_indirect_debugger', node: 'procedure', @@ -107,6 +110,7 @@ export default class DebuggerModule { debug_type: 'indirect', }, enable: 'canDebug', + permission: AllPermissionTypes.TOOLS_DEBUGGER, }, { name: 'trigger_function_indirect_debugger', node: 'trigger_function', @@ -121,6 +125,7 @@ export default class DebuggerModule { debug_type: 'indirect', }, enable: 'canDebug', + permission: AllPermissionTypes.TOOLS_DEBUGGER, }, { name: 'trigger_indirect_debugger', node: 'trigger', @@ -135,6 +140,7 @@ export default class DebuggerModule { debug_type: 'indirect', }, enable: 'canDebug', + permission: AllPermissionTypes.TOOLS_DEBUGGER, }, { name: 'package_function_direct_debugger', node: 'edbfunc', @@ -148,6 +154,7 @@ export default class DebuggerModule { object: 'edbfunc', }, enable: 'canDebug', + permission: AllPermissionTypes.TOOLS_DEBUGGER, }, { name: 'package_function_global_debugger', node: 'edbfunc', @@ -162,6 +169,7 @@ export default class DebuggerModule { debug_type: 'indirect', }, enable: 'canDebug', + permission: AllPermissionTypes.TOOLS_DEBUGGER, }, { name: 'package_procedure_direct_debugger', node: 'edbproc', @@ -175,6 +183,7 @@ export default class DebuggerModule { object: 'edbproc', }, enable: 'canDebug', + permission: AllPermissionTypes.TOOLS_DEBUGGER, }, { name: 'package_procedure_global_debugger', node: 'edbproc', @@ -189,6 +198,7 @@ export default class DebuggerModule { debug_type: 'indirect', }, enable: 'canDebug', + permission: AllPermissionTypes.TOOLS_DEBUGGER, } ]); } diff --git a/web/pgadmin/tools/erd/__init__.py b/web/pgadmin/tools/erd/__init__.py index 8416646e9..d934158f5 100644 --- a/web/pgadmin/tools/erd/__init__.py +++ b/web/pgadmin/tools/erd/__init__.py @@ -12,6 +12,7 @@ import json from flask import url_for, request, Response from flask import render_template, current_app as app +from flask_security import permissions_required from pgadmin.user_login_check import pga_login_required from flask_babel import gettext from werkzeug.user_agent import UserAgent @@ -32,6 +33,7 @@ from pgadmin.utils.constants import PREF_LABEL_KEYBOARD_SHORTCUTS, \ from .utils import ERDHelper from pgadmin.utils.exception import ConnectionLost from pgadmin.authenticate import socket_login_required +from pgadmin.tools.user_management.PgAdminPermissions import AllPermissionTypes from ... import socketio MODULE_NAME = 'erd' @@ -451,6 +453,7 @@ blueprint = ERDModule(MODULE_NAME, __name__, static_url_path='/static') methods=["POST"], endpoint='panel' ) +@permissions_required(AllPermissionTypes.tools_erd_tool) @pga_login_required def panel(trans_id): """ diff --git a/web/pgadmin/tools/erd/static/js/ERDModule.js b/web/pgadmin/tools/erd/static/js/ERDModule.js index 012dc83d8..51b8378ef 100644 --- a/web/pgadmin/tools/erd/static/js/ERDModule.js +++ b/web/pgadmin/tools/erd/static/js/ERDModule.js @@ -16,7 +16,7 @@ import ReactDOM from 'react-dom/client'; import ERDTool from './erd_tool/components/ERDTool'; import ModalProvider from '../../../../static/js/helpers/ModalProvider'; import Theme from '../../../../static/js/Theme'; -import { BROWSER_PANELS } from '../../../../browser/static/js/constants'; +import { AllPermissionTypes, BROWSER_PANELS } from '../../../../browser/static/js/constants'; import { NotifierProvider } from '../../../../static/js/helpers/Notifier'; import usePreferences, { listenPreferenceBroadcast } from '../../../../preferences/static/js/store'; import pgAdmin from 'sources/pgadmin'; @@ -59,6 +59,7 @@ export default class ERDModule { data: { data_disabled: gettext('The selected tree node does not support this option.'), }, + permission: AllPermissionTypes.TOOLS_ERD_TOOL, }]); return this; } diff --git a/web/pgadmin/tools/grant_wizard/__init__.py b/web/pgadmin/tools/grant_wizard/__init__.py index 58501af15..5c7303a92 100644 --- a/web/pgadmin/tools/grant_wizard/__init__.py +++ b/web/pgadmin/tools/grant_wizard/__init__.py @@ -13,6 +13,7 @@ import json from flask import Response, url_for from flask import render_template, request, current_app from flask_babel import gettext +from flask_security import permissions_required from pgadmin.user_login_check import pga_login_required from urllib.parse import unquote @@ -27,6 +28,7 @@ from pgadmin.utils.ajax import precondition_required from functools import wraps from pgadmin.utils.preferences import Preferences from pgadmin.utils.constants import MIMETYPE_APP_JS +from pgadmin.tools.user_management.PgAdminPermissions import AllPermissionTypes # set template path for sql scripts MODULE_NAME = 'grant_wizard' @@ -122,19 +124,10 @@ def index(): ) -@blueprint.route("/grant_wizard.js") -@pga_login_required -def script(): - """render own javascript""" - return Response(response=render_template( - "grant_wizard/js/grant_wizard.js", _=gettext), - status=200, - mimetype=MIMETYPE_APP_JS) - - @blueprint.route( '/acl///', methods=['GET'], endpoint='acl' ) +@permissions_required(AllPermissionTypes.tools_grant_wizard) @pga_login_required @check_precondition def acl_list(sid, did): diff --git a/web/pgadmin/tools/grant_wizard/static/js/grant_wizard.js b/web/pgadmin/tools/grant_wizard/static/js/grant_wizard.js index 82b42221c..a1fe8d789 100644 --- a/web/pgadmin/tools/grant_wizard/static/js/grant_wizard.js +++ b/web/pgadmin/tools/grant_wizard/static/js/grant_wizard.js @@ -8,7 +8,7 @@ ////////////////////////////////////////////////////////////// import React from 'react'; import GrantWizard from './GrantWizard'; -import { BROWSER_PANELS } from '../../../../browser/static/js/constants'; +import { AllPermissionTypes, BROWSER_PANELS } from '../../../../browser/static/js/constants'; // Grant Wizard @@ -49,6 +49,7 @@ define([ data: { data_disabled: gettext('Please select any database, schema or schema objects from the object explorer to access Grant Wizard Tool.'), }, + permission: AllPermissionTypes.TOOLS_GRANT_WIZARD, }]; // Add supported menus into the menus list diff --git a/web/pgadmin/tools/import_export/__init__.py b/web/pgadmin/tools/import_export/__init__.py index dea029b9a..833d2d134 100644 --- a/web/pgadmin/tools/import_export/__init__.py +++ b/web/pgadmin/tools/import_export/__init__.py @@ -14,7 +14,7 @@ import copy from flask import Response, render_template, request, current_app from flask_babel import gettext as _ -from flask_security import current_user +from flask_security import current_user, permissions_required from pgadmin.user_login_check import pga_login_required from pgadmin.misc.bgprocess.processes import BatchProcess, IProcessDesc from pgadmin.utils import PgAdminModule, get_storage_directory, IS_WIN, \ @@ -25,6 +25,7 @@ from config import PG_DEFAULT_DRIVER from pgadmin.model import Server from pgadmin.utils.constants import MIMETYPE_APP_JS, SERVER_NOT_FOUND from pgadmin.settings import get_setting, store_setting +from pgadmin.tools.user_management.PgAdminPermissions import AllPermissionTypes MODULE_NAME = 'import_export' @@ -227,6 +228,7 @@ def _save_import_export_settings(settings): @blueprint.route('/job/', methods=['POST'], endpoint="create_job") +@permissions_required(AllPermissionTypes.tools_import_export_data) @pga_login_required def create_import_export_job(sid): """ diff --git a/web/pgadmin/tools/import_export/static/js/import_export.js b/web/pgadmin/tools/import_export/static/js/import_export.js index b329cea63..fbaf34070 100644 --- a/web/pgadmin/tools/import_export/static/js/import_export.js +++ b/web/pgadmin/tools/import_export/static/js/import_export.js @@ -10,6 +10,7 @@ import getApiInstance from 'sources/api_instance'; import ImportExportSchema from './import_export.ui'; import { getNodeListByName, getNodeAjaxOptions } from '../../../../browser/static/js/node_ajax'; +import { AllPermissionTypes } from '../../../../browser/static/js/constants'; define([ 'sources/gettext', 'sources/url_for', @@ -51,6 +52,7 @@ define([ data: { data_disabled: gettext('Please select any table from the object explorer to Import/Export data.'), }, + permission: AllPermissionTypes.TOOLS_IMPORT_EXPORT_DATA, }]); }, getUISchema: function(treeItem) { diff --git a/web/pgadmin/tools/import_export_servers/__init__.py b/web/pgadmin/tools/import_export_servers/__init__.py index 1c6e1f76e..d02844e59 100644 --- a/web/pgadmin/tools/import_export_servers/__init__.py +++ b/web/pgadmin/tools/import_export_servers/__init__.py @@ -16,7 +16,7 @@ import secrets from flask import Response, render_template, request from flask_babel import gettext as _ -from flask_security import current_user +from flask_security import current_user, permissions_required from pgadmin.user_login_check import pga_login_required from pgadmin.utils import PgAdminModule from pgadmin.utils.ajax import bad_request @@ -27,7 +27,7 @@ from pgadmin.model import ServerGroup, Server from pgadmin.utils import clear_database_servers, dump_database_servers,\ load_database_servers, validate_json_data, filename_with_file_manager_path from urllib.parse import unquote -from pgadmin.utils.paths import get_storage_directory +from pgadmin.tools.user_management.PgAdminPermissions import AllPermissionTypes MODULE_NAME = 'import_export_servers' @@ -60,18 +60,6 @@ def index(): return bad_request(errormsg=_("This URL cannot be called directly.")) -@blueprint.route("/js/import_export_servers.js") -@pga_login_required -def script(): - """render the import/export javascript file""" - return Response( - response=render_template( - "import_export_servers/js/import_export_servers.js", _=_), - status=200, - mimetype=MIMETYPE_APP_JS - ) - - @blueprint.route('/get_servers', methods=['GET'], endpoint='get_servers') @pga_login_required def get_servers(): @@ -169,6 +157,7 @@ def load_servers(): @blueprint.route('/save', methods=['POST'], endpoint='save') +@permissions_required(AllPermissionTypes.tools_import_export_servers) @pga_login_required def save(): """ diff --git a/web/pgadmin/tools/import_export_servers/static/js/import_export_servers.js b/web/pgadmin/tools/import_export_servers/static/js/import_export_servers.js index 6484ad1b3..c433157bc 100644 --- a/web/pgadmin/tools/import_export_servers/static/js/import_export_servers.js +++ b/web/pgadmin/tools/import_export_servers/static/js/import_export_servers.js @@ -38,6 +38,7 @@ export default class ImportExportServersModule { enable: isDefaultWorkspace, priority: 3, label: gettext('Import/Export Servers...'), + permission: 'tools_import_export_servers', }]; pgAdmin.Browser.add_menus(menus); diff --git a/web/pgadmin/tools/maintenance/__init__.py b/web/pgadmin/tools/maintenance/__init__.py index c2c8d2da6..ecc830d5e 100644 --- a/web/pgadmin/tools/maintenance/__init__.py +++ b/web/pgadmin/tools/maintenance/__init__.py @@ -13,6 +13,7 @@ import json from flask import Response, render_template, request, current_app from flask_babel import gettext as _ +from flask_security import permissions_required from pgadmin.user_login_check import pga_login_required from pgadmin.misc.bgprocess.processes import BatchProcess, IProcessDesc from pgadmin.utils import PgAdminModule, html, does_utility_exist, get_server @@ -22,6 +23,7 @@ from pgadmin.utils.driver import get_driver from config import PG_DEFAULT_DRIVER from pgadmin.model import Server, SharedServer from pgadmin.utils.constants import MIMETYPE_APP_JS, SERVER_NOT_FOUND +from pgadmin.tools.user_management.PgAdminPermissions import AllPermissionTypes MODULE_NAME = 'maintenance' @@ -129,17 +131,6 @@ def index(): ) -@blueprint.route("/js/maintenance.js") -@pga_login_required -def script(): - """render the maintenance tool of vacuum javascript file""" - return Response( - response=render_template("maintenance/js/maintenance.js", _=_), - status=200, - mimetype=MIMETYPE_APP_JS - ) - - def get_index_name(data): """ Check and get index name from constraints. @@ -160,6 +151,7 @@ def get_index_name(data): @blueprint.route( '/job//', methods=['POST'], endpoint='create_job' ) +@permissions_required(AllPermissionTypes.tools_maintenance) @pga_login_required def create_maintenance_job(sid, did): """ diff --git a/web/pgadmin/tools/maintenance/static/js/maintenance.js b/web/pgadmin/tools/maintenance/static/js/maintenance.js index b8e6b942d..bfa50739d 100644 --- a/web/pgadmin/tools/maintenance/static/js/maintenance.js +++ b/web/pgadmin/tools/maintenance/static/js/maintenance.js @@ -10,6 +10,7 @@ import getApiInstance from 'sources/api_instance'; import MaintenanceSchema, {getVacuumSchema} from './maintenance.ui'; import { getNodeListByName } from '../../../../browser/static/js/node_ajax'; +import { AllPermissionTypes } from '../../../../browser/static/js/constants'; define([ 'sources/gettext', 'sources/url_for', 'sources/pgadmin', 'pgadmin.browser', @@ -51,6 +52,7 @@ define([ data: { data_disabled: gettext('Please select any database from the object explorer to do Maintenance.'), }, + permission: AllPermissionTypes.TOOLS_MAINTENANCE, }]; // Add supported menus into the menus list diff --git a/web/pgadmin/tools/psql/static/js/PsqlModule.js b/web/pgadmin/tools/psql/static/js/PsqlModule.js index b2d587179..cfe0309a0 100644 --- a/web/pgadmin/tools/psql/static/js/PsqlModule.js +++ b/web/pgadmin/tools/psql/static/js/PsqlModule.js @@ -10,7 +10,7 @@ import { getRandomInt, hasBinariesConfiguration } from 'sources/utils'; import { retrieveAncestorOfTypeServer } from 'sources/tree/tree_utils'; import { generateTitle } from 'tools/sqleditor/static/js/sqleditor_title'; -import { BROWSER_PANELS } from '../../../../browser/static/js/constants'; +import { AllPermissionTypes, BROWSER_PANELS } from '../../../../browser/static/js/constants'; import usePreferences,{ listenPreferenceBroadcast } from '../../../../preferences/static/js/store'; import 'pgadmin.browser.keyboard'; import pgWindow from 'sources/window'; @@ -93,6 +93,7 @@ export default class Psql { applies: 'tools', data_disabled: gettext('Please select a database from the object explorer to access Pql Tool.'), }, + permission: AllPermissionTypes.TOOLS_PSQL_TOOL, }]; diff --git a/web/pgadmin/tools/restore/__init__.py b/web/pgadmin/tools/restore/__init__.py index 74c5b45da..45d3d6393 100644 --- a/web/pgadmin/tools/restore/__init__.py +++ b/web/pgadmin/tools/restore/__init__.py @@ -15,7 +15,7 @@ from flask import render_template, request, current_app, Response from flask_babel import gettext as _ # This unused import is required as API test cases will fail if we remove it, # Have to identify the cause and then remove it. -from flask_security import current_user +from flask_security import current_user, permissions_required from pgadmin.user_login_check import pga_login_required from pgadmin.misc.bgprocess.processes import BatchProcess, IProcessDesc from pgadmin.utils import PgAdminModule, fs_short_path, does_utility_exist, \ @@ -25,6 +25,7 @@ from pgadmin.utils.ajax import make_json_response, bad_request, \ from config import PG_DEFAULT_DRIVER from pgadmin.utils.constants import MIMETYPE_APP_JS, SERVER_NOT_FOUND +from pgadmin.tools.user_management.PgAdminPermissions import AllPermissionTypes # set template path for sql scripts MODULE_NAME = 'restore' @@ -117,19 +118,6 @@ def index(): return bad_request(errormsg=_("This URL cannot be called directly.")) -@blueprint.route("/restore.js") -@pga_login_required -def script(): - """render own javascript""" - return Response( - response=render_template( - "restore/js/restore.js", _=_ - ), - status=200, - mimetype=MIMETYPE_APP_JS - ) - - def _get_create_req_data(): """ Get data from request for create restore job. @@ -398,6 +386,7 @@ def use_sql_utility(data, manager, server, filepath): @blueprint.route('/job/', methods=['POST'], endpoint='create_job') +@permissions_required(AllPermissionTypes.tools_restore) @pga_login_required def create_restore_job(sid): """ diff --git a/web/pgadmin/tools/restore/static/js/restore.js b/web/pgadmin/tools/restore/static/js/restore.js index c262e5b83..b52845b0e 100644 --- a/web/pgadmin/tools/restore/static/js/restore.js +++ b/web/pgadmin/tools/restore/static/js/restore.js @@ -12,6 +12,7 @@ import getApiInstance from 'sources/api_instance'; import {retrieveAncestorOfTypeServer} from 'sources/tree/tree_utils'; import RestoreSchema, {getRestoreSaveOptSchema, getRestoreDisableOptionSchema, getRestoreMiscellaneousSchema, getRestoreTypeObjSchema, getRestoreSectionSchema} from './restore.ui'; import pgAdmin from 'sources/pgadmin'; +import { AllPermissionTypes } from '../../../../browser/static/js/constants'; define('tools.restore', [ 'sources/gettext', 'sources/url_for', 'pgadmin.browser', @@ -49,6 +50,7 @@ define('tools.restore', [ data: { data_disabled: gettext('Please select any schema or table from the object explorer to Restore data.'), }, + permission: AllPermissionTypes.TOOLS_RESTORE, }]; for (let sup_node_val of menuUtils.restoreSupportedNodes) { @@ -63,6 +65,7 @@ define('tools.restore', [ enable: supportedNodes.enabled.bind( null, pgBrowser.tree, menuUtils.restoreSupportedNodes ), + permission: AllPermissionTypes.TOOLS_RESTORE, }); } diff --git a/web/pgadmin/tools/schema_diff/__init__.py b/web/pgadmin/tools/schema_diff/__init__.py index 7ea45b268..0d4747de9 100644 --- a/web/pgadmin/tools/schema_diff/__init__.py +++ b/web/pgadmin/tools/schema_diff/__init__.py @@ -15,7 +15,7 @@ import copy from flask import Response, session, url_for, request from flask import render_template, current_app as app -from flask_security import current_user +from flask_security import current_user, permissions_required from pgadmin.user_login_check import pga_login_required from flask_babel import gettext from pgadmin.utils import PgAdminModule @@ -31,6 +31,7 @@ from pgadmin.utils.constants import PREF_LABEL_DISPLAY, MIMETYPE_APP_JS,\ from sqlalchemy import or_ from pgadmin.authenticate import socket_login_required from pgadmin import socketio +from pgadmin.tools.user_management.PgAdminPermissions import AllPermissionTypes MODULE_NAME = 'schema_diff' COMPARE_MSG = gettext("Comparing objects...") @@ -123,6 +124,8 @@ def index(): methods=["GET"], endpoint='panel' ) +@permissions_required(AllPermissionTypes.tools_schema_diff) +@pga_login_required def panel(trans_id, editor_title): """ This method calls index.html to render the schema diff. diff --git a/web/pgadmin/tools/schema_diff/static/js/SchemaDiffModule.js b/web/pgadmin/tools/schema_diff/static/js/SchemaDiffModule.js index c4488e20f..7240d7aa5 100644 --- a/web/pgadmin/tools/schema_diff/static/js/SchemaDiffModule.js +++ b/web/pgadmin/tools/schema_diff/static/js/SchemaDiffModule.js @@ -19,7 +19,7 @@ import getApiInstance from '../../../../static/js/api_instance'; import Theme from '../../../../static/js/Theme'; import ModalProvider from '../../../../static/js/helpers/ModalProvider'; import SchemaDiffComponent from './components/SchemaDiffComponent'; -import { BROWSER_PANELS } from '../../../../browser/static/js/constants'; +import { AllPermissionTypes, BROWSER_PANELS } from '../../../../browser/static/js/constants'; import { NotifierProvider } from '../../../../static/js/helpers/Notifier'; import usePreferences, { listenPreferenceBroadcast } from '../../../../preferences/static/js/store'; import pgAdmin from 'sources/pgadmin'; @@ -57,6 +57,7 @@ export default class SchemaDiff { label: gettext('Schema Diff'), enable: true, below: true, + permission: AllPermissionTypes.TOOLS_SCHEMA_DIFF, }]); } diff --git a/web/pgadmin/tools/search_objects/__init__.py b/web/pgadmin/tools/search_objects/__init__.py index 091f612bb..553216058 100644 --- a/web/pgadmin/tools/search_objects/__init__.py +++ b/web/pgadmin/tools/search_objects/__init__.py @@ -11,6 +11,7 @@ from flask import request from flask_babel import gettext +from flask_security import permissions_required from pgadmin.user_login_check import pga_login_required from pgadmin.utils import PgAdminModule @@ -18,6 +19,7 @@ from pgadmin.utils.ajax import make_json_response, bad_request,\ internal_server_error from pgadmin.utils.preferences import Preferences from pgadmin.tools.search_objects.utils import SearchObjectsHelper +from pgadmin.tools.user_management.PgAdminPermissions import AllPermissionTypes MODULE_NAME = 'search_objects' @@ -67,6 +69,7 @@ def types(sid, did): @blueprint.route("search//", endpoint='search') +@permissions_required(AllPermissionTypes.tools_search_objects) @pga_login_required def search(sid, did): """ diff --git a/web/pgadmin/tools/search_objects/static/js/index.js b/web/pgadmin/tools/search_objects/static/js/index.js index d71016c2f..231acdd25 100644 --- a/web/pgadmin/tools/search_objects/static/js/index.js +++ b/web/pgadmin/tools/search_objects/static/js/index.js @@ -47,6 +47,7 @@ export default class SearchObjectModule { data: { data_disabled: gettext('Please select a database from the object explorer to search the database objects.'), }, + permission: 'tools_search_objects', }]; pgBrowser.add_menus(menus); diff --git a/web/pgadmin/tools/sqleditor/__init__.py b/web/pgadmin/tools/sqleditor/__init__.py index 152ae82a8..0a6ecf03d 100644 --- a/web/pgadmin/tools/sqleditor/__init__.py +++ b/web/pgadmin/tools/sqleditor/__init__.py @@ -29,7 +29,7 @@ from flask_babel import gettext from pgadmin.tools.sqleditor.utils.query_tool_connection_check \ import query_tool_connection_check from pgadmin.user_login_check import pga_login_required -from flask_security import current_user +from flask_security import current_user, permissions_required from pgadmin.misc.file_manager import Filemanager from pgadmin.tools.sqleditor.command import QueryToolCommand, ObjectRegistry, \ SQLFilter @@ -67,6 +67,7 @@ from pgadmin.settings import get_setting from pgadmin.utils.preferences import Preferences from pgadmin.tools.sqleditor.utils.apply_explain_plan_wrapper import \ get_explain_query_length +from pgadmin.tools.user_management.PgAdminPermissions import AllPermissionTypes from pgadmin.browser.server_groups.servers.utils import \ convert_connection_parameter, get_db_disp_restriction from pgadmin.misc.workspaces import check_and_delete_adhoc_server @@ -295,6 +296,7 @@ def initialize_viewdata(trans_id, cmd_type, obj_type, sgid, sid, did, obj_id): methods=["POST"], endpoint='panel' ) +@pga_login_required def panel(trans_id): """ This method calls index.html to render the data grid. @@ -375,6 +377,7 @@ def panel(trans_id): '/initialize/sqleditor///', methods=["POST"], endpoint='initialize_sqleditor' ) +@permissions_required(AllPermissionTypes.tools_query_tool) @pga_login_required def initialize_sqleditor(trans_id, sgid, sid, did=None): """ diff --git a/web/pgadmin/tools/sqleditor/static/js/SQLEditorModule.js b/web/pgadmin/tools/sqleditor/static/js/SQLEditorModule.js index 1a50bd7f4..75fb850c4 100644 --- a/web/pgadmin/tools/sqleditor/static/js/SQLEditorModule.js +++ b/web/pgadmin/tools/sqleditor/static/js/SQLEditorModule.js @@ -23,7 +23,7 @@ import ReactDOM from 'react-dom/client'; import QueryToolComponent from './components/QueryToolComponent'; import ModalProvider from '../../../../static/js/helpers/ModalProvider'; import Theme from '../../../../static/js/Theme'; -import { BROWSER_PANELS, WORKSPACES } from '../../../../browser/static/js/constants'; +import { AllPermissionTypes, BROWSER_PANELS, WORKSPACES } from '../../../../browser/static/js/constants'; import { NotifierProvider } from '../../../../static/js/helpers/Notifier'; import usePreferences, { listenPreferenceBroadcast } from '../../../../preferences/static/js/store'; import { PgAdminProvider } from '../../../../static/js/PgAdminProvider'; @@ -104,6 +104,7 @@ export default class SQLEditor { applies: 'tools', data_disabled: gettext('Please select a database from the object explorer to access Query Tool.'), }, + permission: AllPermissionTypes.TOOLS_QUERY_TOOL, }]; // Create context menu @@ -121,6 +122,7 @@ export default class SQLEditor { category: 'view_data', priority: 101, label: gettext('All Rows'), + permission: AllPermissionTypes.TOOLS_QUERY_TOOL, }, { name: 'view_first_100_rows_context_' + supportedNode, node: supportedNode, @@ -134,6 +136,7 @@ export default class SQLEditor { category: 'view_data', priority: 102, label: gettext('First 100 Rows'), + permission: AllPermissionTypes.TOOLS_QUERY_TOOL, }, { name: 'view_last_100_rows_context_' + supportedNode, node: supportedNode, @@ -147,6 +150,7 @@ export default class SQLEditor { category: 'view_data', priority: 103, label: gettext('Last 100 Rows'), + permission: AllPermissionTypes.TOOLS_QUERY_TOOL, }, { name: 'view_filtered_rows_context_' + supportedNode, node: supportedNode, @@ -160,6 +164,7 @@ export default class SQLEditor { category: 'view_data', priority: 104, label: gettext('Filtered Rows...'), + permission: AllPermissionTypes.TOOLS_QUERY_TOOL, }); } diff --git a/web/pgadmin/tools/user_management/PgAdminPermissions.py b/web/pgadmin/tools/user_management/PgAdminPermissions.py new file mode 100644 index 000000000..5a7fc594c --- /dev/null +++ b/web/pgadmin/tools/user_management/PgAdminPermissions.py @@ -0,0 +1,135 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +from flask_babel import gettext + + +class AllPermissionTypes: + object_register_server = 'object_register_server' + tools_erd_tool = 'tools_erd_tool' + tools_query_tool = 'tools_query_tool' + tools_debugger = 'tools_debugger' + tools_psql_tool = 'tools_psql_tool' + tools_backup = 'tools_backup' + tools_restore = 'tools_restore' + tools_import_export_data = 'tools_import_export_data' + tools_import_export_servers = 'tools_import_export_servers' + tools_search_objects = 'tools_search_objects' + tools_maintenance = 'tools_maintenance' + tools_schema_diff = 'tools_schema_diff' + tools_grant_wizard = 'tools_grant_wizard' + storage_add_folder = 'storage_add_folder' + storage_remove_folder = 'storage_remove_folder' + + @staticmethod + def list(): + return filter(lambda x: not x.startswith('_'), + AllPermissionTypes.__dict__.keys()) + + +class AllPermissionCategories: + object_explorer = 'Object Explorer' + tools = 'Tools' + storage_manager = 'Storage Manager' + + +class PgAdminPermissions: + _all_permissions = [] + + def __init__(self): + self.add_permission( + AllPermissionCategories.object_explorer, + AllPermissionTypes.object_register_server, + gettext("Register/remove server") + ) + self.add_permission( + AllPermissionCategories.tools, + AllPermissionTypes.tools_query_tool, + gettext("Query tool") + ) + self.add_permission( + AllPermissionCategories.tools, + AllPermissionTypes.tools_debugger, + gettext("Debugger") + ) + self.add_permission( + AllPermissionCategories.tools, + AllPermissionTypes.tools_psql_tool, + gettext("PSQL tool") + ) + self.add_permission( + AllPermissionCategories.tools, + AllPermissionTypes.tools_backup, + gettext("Backup tool (including server and globals)") + ) + self.add_permission( + AllPermissionCategories.tools, + AllPermissionTypes.tools_restore, + gettext("Restore tool") + ) + self.add_permission( + AllPermissionCategories.tools, + AllPermissionTypes.tools_import_export_data, + gettext("Import/export data") + ) + self.add_permission( + AllPermissionCategories.tools, + AllPermissionTypes.tools_import_export_servers, + gettext("Import/export servers") + ) + self.add_permission( + AllPermissionCategories.tools, + AllPermissionTypes.tools_search_objects, + gettext("Search objects") + ) + self.add_permission( + AllPermissionCategories.tools, + AllPermissionTypes.tools_maintenance, + gettext("Maintenance") + ) + self.add_permission( + AllPermissionCategories.tools, + AllPermissionTypes.tools_schema_diff, + gettext("Schema diff") + ) + self.add_permission( + AllPermissionCategories.tools, + AllPermissionTypes.tools_grant_wizard, + gettext("Grant wizard") + ) + self.add_permission( + AllPermissionCategories.tools, + AllPermissionTypes.tools_erd_tool, + gettext("ERD tool") + ) + self.add_permission( + AllPermissionCategories.storage_manager, + AllPermissionTypes.storage_add_folder, + gettext("Add folder") + ) + self.add_permission( + AllPermissionCategories.storage_manager, + AllPermissionTypes.storage_remove_folder, + gettext("Delete file/folder") + ) + + def add_permission(self, category: str, permission: str, label: str): + self._all_permissions.append({ + "category": category, + "name": permission, + "label": label, + }) + + @property + def all_permissions(self): + return sorted( + self._all_permissions, + key=lambda x: ( + x['category'], + x['label'])) diff --git a/web/pgadmin/tools/user_management/__init__.py b/web/pgadmin/tools/user_management/__init__.py index 1c6a78e44..3270ec41c 100644 --- a/web/pgadmin/tools/user_management/__init__.py +++ b/web/pgadmin/tools/user_management/__init__.py @@ -27,17 +27,21 @@ from pgadmin.utils import PgAdminModule from pgadmin.utils.ajax import make_response as ajax_response, \ make_json_response, bad_request, internal_server_error from pgadmin.utils.csrf import pgCSRFProtect -from pgadmin.utils.constants import MIMETYPE_APP_JS, INTERNAL,\ +from pgadmin.utils.constants import MIMETYPE_APP_JS, INTERNAL, \ SUPPORTED_AUTH_SOURCES from pgadmin.utils.validation_utils import validate_email from pgadmin.model import db, Role, User, UserPreference, Server, \ ServerGroup, Process, Setting, roles_users, SharedServer from pgadmin.utils.paths import create_users_storage_directory +from pgadmin.tools.user_management.PgAdminPermissions import PgAdminPermissions +from sqlalchemy import func # set template path for sql scripts MODULE_NAME = 'user_management' server_info = {} +permissions_obj = PgAdminPermissions() + class UserManagementModule(PgAdminModule): """ @@ -62,13 +66,21 @@ class UserManagementModule(PgAdminModule): list: URL endpoints for backup module """ return [ - 'user_management.roles', 'user_management.role', - 'user_management.users', 'user_management.user', + 'user_management.roles', + 'user_management.role', + 'user_management.role_save', + 'user_management.role_delete', + 'user_management.users', + 'user_management.user', current_app.login_manager.login_view, - 'user_management.auth_sources', 'user_management.change_owner', - 'user_management.shared_servers', 'user_management.admin_users', - 'user_management.save', 'user_management.save_id' - ] + 'user_management.auth_sources', + 'user_management.change_owner', + 'user_management.shared_servers', + 'user_management.admin_users', + 'user_management.save', + 'user_management.save_id', + 'user_management.all_permissions', + 'user_management.save_permissions'] # Create blueprint for BackupModule class @@ -83,35 +95,21 @@ def index(): return bad_request(errormsg=_("This URL cannot be called directly.")) -@blueprint.route("/user_management.js") -@pga_login_required -def script(): - """render own javascript""" - return Response( - response=render_template( - "user_management/js/user_management.js", _=_, - is_admin=current_user.has_role("Administrator"), - user_id=current_user.id - ), - status=200, - mimetype=MIMETYPE_APP_JS - ) - - @blueprint.route("/current_user.js") @pgCSRFProtect.exempt @pga_login_required def current_user_info(): + current_user.has_permission return Response( response=render_template( "user_management/js/current_user.js", is_admin='true' if current_user.has_role( "Administrator") else 'false', user_id=current_user.id, - email=current_user.email.replace("'","\\'") if current_user.email + email=current_user.email.replace("'", "\\'") if current_user.email else current_user.email, name=( - current_user.username.split('@')[0].replace("'","\\'") if + current_user.username.split('@')[0].replace("'", "\\'") if config.SERVER_MODE is True else 'postgres' ), @@ -124,8 +122,13 @@ def current_user_info(): session.get('allow_save_password', None) else 'false', auth_sources=config.AUTHENTICATION_SOURCES, current_auth_source=session['auth_source_manager'][ - 'current_source'] if config.SERVER_MODE is True else INTERNAL + 'current_source'] if config.SERVER_MODE is True else INTERNAL, + permissions=list({p for r in current_user.roles + for p in r.get_permissions()}) ), + headers={ + 'Cache-Control': 'no-cache, no-store, must-revalidate' + }, status=200, mimetype=MIMETYPE_APP_JS ) @@ -348,6 +351,81 @@ def admin_users(uid=None): ) +def create_role(data): + try: + validate_unique_role(data) + r = Role(name=data['name'], + description=data['description']) + db.session.add(r) + db.session.commit() + return ajax_response( + status=200 + ) + except Exception as e: + db.session.rollback() + current_app.logger.exception(e) + return internal_server_error(str(e)) + + +def update_role(rid, data): + try: + validate_unique_role(data) + r = Role.query.get(rid) + + if not r: + return ajax_response( + response=_('Role not found'), + status=404 + ) + + for key, value in data.items(): + setattr(r, key, value) + + db.session.commit() + return ajax_response( + status=200 + ) + except Exception as e: + db.session.rollback() + current_app.logger.exception(e) + return internal_server_error(str(e)) + + +def delete_role(rid): + r = Role.query.get(rid) + + if not r: + return ajax_response( + response=_('Role not found'), + status=404 + ) + + users = User.query.all() + + for u in users: + if u.has_role(r): + return make_json_response( + success=0, + status=400, + errormsg=_( + 'To proceed, ensure that all users assigned ' + 'the \'{0}\' role have been reassigned.'.format(r.name)) + ) + + try: + # Finally delete user + db.session.delete(r) + db.session.commit() + + return ajax_response( + status=200 + ) + except Exception as e: + db.session.rollback() + current_app.logger.exception(e) + return internal_server_error(str(e)) + + @blueprint.route( '/role/', methods=['GET'], defaults={'rid': None}, endpoint='roles' ) @@ -366,14 +444,21 @@ def role(rid): if rid: r = Role.query.get(rid) - res = {'id': r.id, 'name': r.name} + res = {'id': r.id, + 'name': r.name, + 'description': r.description, + 'permissions': r.permissions, + 'is_admin': r.name == "Administrator"} else: roles = Role.query.all() roles_data = [] for r in roles: roles_data.append({'id': r.id, - 'name': r.name}) + 'name': r.name, + 'description': r.description, + 'permissions': r.permissions, + 'is_admin': r.name == "Administrator"}) res = roles_data @@ -383,6 +468,32 @@ def role(rid): ) +@blueprint.route( + '/role/', methods=['POST'], defaults={'id': None}, endpoint='role_save' +) +@blueprint.route('/role/', methods=['DELETE'], endpoint='role_delete') +@roles_required('Administrator') +def role_save(id): + """ + + Args: + id: Role id + + """ + + if request.method == 'DELETE': + return delete_role(id) + + data = request.form if request.form else json.loads( + request.data + ) + + if 'id' not in data: + return create_role(data) + else: + return update_role(data['id'], data) + + @blueprint.route( '/auth_sources/', methods=['GET'], endpoint='auth_sources' ) @@ -446,6 +557,18 @@ def normalise_password(password): normalize(normalise_form, password) +def validate_unique_role(data): + if 'name' not in data: + return + + exist_roles = Role.query.filter( + func.lower(Role.name) == func.lower(data['name']) + ).count() + + if exist_roles != 0: + raise InternalServerError(_("Role name must be unique.")) + + def validate_password(data, new_data): """ Check password new and confirm password match. If both passwords are not @@ -652,3 +775,41 @@ def delete_user(uid): return False, str(e) return True, '' + + +@blueprint.route('/all_permissions', + methods=['GET'], + endpoint='all_permissions') +@roles_required('Administrator') +def get_all_permissions(): + return ajax_response( + status=200, + response=permissions_obj.all_permissions + ) + + +@blueprint.route('/save_permissions/', + methods=['PUT'], endpoint='save_permissions') +@roles_required('Administrator') +def save_permissions(id): + data = request.form if request.form else json.loads( + request.data + ) + + r = Role.query.get(id) + + try: + r.permissions = data['permissions'] + db.session.commit() + except Exception as e: + db.session.rollback() + return internal_server_error(errormsg=str(e)) + + return ajax_response( + status=200, + response={ + 'id': r.id, + 'name': r.name, + 'permissions': r.permissions + } + ) diff --git a/web/pgadmin/tools/user_management/static/js/Component.jsx b/web/pgadmin/tools/user_management/static/js/Component.jsx index 8b4046802..601154349 100644 --- a/web/pgadmin/tools/user_management/static/js/Component.jsx +++ b/web/pgadmin/tools/user_management/static/js/Component.jsx @@ -7,10 +7,14 @@ // ////////////////////////////////////////////////////////////// -import React from 'react'; +import React, { useEffect } from 'react'; import { Box, styled, Tab, Tabs } from '@mui/material'; import TabPanel from '../../../../static/js/components/TabPanel'; +import url_for from 'sources/url_for'; import Users from './Users'; +import Permissions from './Permissions'; +import getApiInstance from '../../../../static/js/api_instance'; +import Roles from './Roles'; const Root = styled('div')(({theme}) => ({ height: '100%', @@ -23,12 +27,33 @@ const Root = styled('div')(({theme}) => ({ flexGrow: 1, display: 'flex', flexDirection: 'column', + minHeight: 0, ...theme.mixins.panelBorder.all, } })); export default function Component() { const [tabValue, setTabValue] = React.useState(0); + const [roles, setRoles] = React.useState([]); + + const fetchRoles = async () => { + const url = url_for('user_management.roles'); + const response = await getApiInstance().get(url); + setRoles(response.data); + }; + + const updateRolePermissions = (rid, permissions) => { + setRoles(roles.map((r) => { + if (r.id === rid) { + return {...r, permissions}; + } + return r; + })); + }; + + useEffect(() => { + fetchRoles(); + }, []); return ( @@ -44,10 +69,18 @@ export default function Component() { action={(ref)=>ref?.updateIndicator()} > + + - + + + + + + + diff --git a/web/pgadmin/tools/user_management/static/js/Permissions.jsx b/web/pgadmin/tools/user_management/static/js/Permissions.jsx new file mode 100644 index 000000000..4316aae29 --- /dev/null +++ b/web/pgadmin/tools/user_management/static/js/Permissions.jsx @@ -0,0 +1,202 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2025, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import React, { useMemo, useEffect } from 'react'; +import url_for from 'sources/url_for'; +import gettext from 'sources/gettext'; +import getApiInstance, { parseApiError } from '../../../../static/js/api_instance'; +import { Box, FormLabel } from '@mui/material'; +import SectionContainer from '../../../../dashboard/static/js/components/SectionContainer'; +import { InputCheckbox, InputSelect, InputText } from '../../../../static/js/components/FormComponents'; +import { SearchRounded } from '@mui/icons-material'; +import { PgButtonGroup, PgIconButton, PrimaryButton } from '../../../../static/js/components/Buttons'; +import { usePgAdmin } from '../../../../static/js/PgAdminProvider'; +import Loader from 'sources/components/Loader'; +import SelectAllRoundedIcon from '@mui/icons-material/SelectAllRounded'; +import DeselectRoundedIcon from '@mui/icons-material/DeselectRounded'; +import PropTypes from 'prop-types'; + +function PermissionsForRole({sections, selectedPerms, setSelectedPerms}) { + return ( + + {Object.keys(sections).map(section => { + const items = sections[section]; + + return + {section} + + + } + aria-label="Select All" + title={gettext('Select All')} + onClick={() => { + setSelectedPerms((prev) => { + return Array.from(new Set([...prev, ...items.map(i => i.name)])); + }); + }} + > + } + aria-label="Deselect All" + title={gettext('Deselect All')} + onClick={() => { + setSelectedPerms((prev) => { + return prev.filter((p) => !items.map(i => i.name).includes(p)); + }); + }} + > + + + + } style={{minHeight: 0, height: 'auto'}}> + + {items.map(item => ( + { + let val = e.target.checked; + setSelectedPerms((prev) => { + if (val) { + return [...prev, item.name]; + } else { + return prev.filter((p) => p !== item.name); + } + }); + }} + sx={{widht: 'fit-content'}} + /> + ))} + + ; + })} + + ); +} +PermissionsForRole.propTypes = { + sections: PropTypes.object, + selectedPerms: PropTypes.array, + setSelectedPerms: PropTypes.func, +}; + +export default function Permissions({roles, updateRolePermissions}) { + const api = getApiInstance(); + const [allPermissions, setAllPermissions] = React.useState([]); + const [searchVal, setSearchVal] = React.useState(''); + const [selectedPerms, setSelectedPerms] = React.useState([]); + const [selectedRole, setSelectedRole] = React.useState(); + const [loading, setLoading] = React.useState(''); + const pgAdmin = usePgAdmin(); + + const isDirty = useMemo(() => { + return JSON.stringify(roles.find((r)=>r.id === selectedRole)?.permissions.sort() || []) !== JSON.stringify(selectedPerms.sort()); + }, [selectedRole, selectedPerms, roles]); + + const savePermissions = async () => { + const url = url_for('user_management.save_permissions', {id: selectedRole}); + try { + setLoading(gettext('Saving...')); + const resp = await api.put(url, {permissions: selectedPerms}); + updateRolePermissions(selectedRole, resp.data.permissions); + pgAdmin.Browser.notifier.success(gettext('Permissions saved successfully')); + } catch (error) { + pgAdmin.Browser.notifier.error(parseApiError(error)); + console.error(error); + } + setLoading(''); + }; + + useEffect(() => { + const url = url_for('user_management.all_permissions'); + api.get(url) + .then(response => { + setAllPermissions(response.data); + }) + .catch(error => { + pgAdmin.Browser.notifier.error(parseApiError(error)); + console.error(error); + }); + }, []); + + useEffect(() => { + setSelectedPerms(roles.find((r)=>r.id === selectedRole)?.permissions || []); + }, [selectedRole]); + + useEffect(() => { + if (selectedRole) { + const role = roles.find((r)=>r.id === selectedRole); + if (!role) { + setSelectedRole(undefined); + } + } + }, [roles]); + + const filteredAllPermissions = useMemo(() => { + return allPermissions.filter(perm => perm.label.toLowerCase().includes(searchVal.toLowerCase())); + }, [allPermissions, searchVal]); + + // Convert the permissions array to section based dict + const sections = useMemo(()=>{ + return filteredAllPermissions.reduce((acc, perm) => { + let section = perm.category; + if (!acc[section]) { + acc[section] = []; + } + acc[section].push(perm); + return acc; + }, {}); + }, [filteredAllPermissions]); + + return ( + + + + {gettext('Role')} + + r.name != 'Administrator').map((r) => ({ label: r.name, value: r.id }))} + optionsReloadBasis={roles.map((r)=>r.name).join('')} + onChange={(val) => {setSelectedRole(val);}} + value={selectedRole} + placeholder={gettext('Select Role')} + /> + + {gettext('Save')} + + { + setSearchVal(val); + }} + startAdornment={} + /> + + + {selectedRole && + + + } + + ); +} + +Permissions.propTypes = { + roles: PropTypes.array.isRequired, + updateRolePermissions: PropTypes.func.isRequired, +}; diff --git a/web/pgadmin/tools/user_management/static/js/RoleDialog.jsx b/web/pgadmin/tools/user_management/static/js/RoleDialog.jsx new file mode 100644 index 000000000..cef92c168 --- /dev/null +++ b/web/pgadmin/tools/user_management/static/js/RoleDialog.jsx @@ -0,0 +1,84 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2025, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import React, { useMemo } from 'react'; +import SchemaView from '../../../../static/js/SchemaView'; +import BaseUISchema from '../../../../static/js/SchemaView/base_schema.ui'; +import gettext from 'sources/gettext'; +import url_for from 'sources/url_for'; +import getApiInstance, { parseApiError } from '../../../../static/js/api_instance'; +import ErrorBoundary from '../../../../static/js/helpers/ErrorBoundary'; +import PropTypes from 'prop-types'; +import { usePgAdmin } from '../../../../static/js/PgAdminProvider'; + +class RoleSchema extends BaseUISchema { + constructor() { + super({ + name: '', + }); + } + + get baseFields() { + return [ + { + id: 'name', label: gettext('Name'), type: 'text', noEmpty: true, maxLength: 128, + }, + { + id: 'description', label: gettext('Description'), type: 'multiline', noEmpty: true, maxLength: 256, + } + ]; + } +} + +export default function RoleDialog({role, onClose}) { + const pgAdmin = usePgAdmin(); + const schema = useMemo(() => new RoleSchema(), []); + const isEdit = Boolean(role.id); + const api = getApiInstance(); + + const onSaveClick = (_isNew, changeData)=>{ + return new Promise((resolve, reject)=>{ + try { + api.post(url_for('user_management.role_save'), changeData) + .then(()=>{ + pgAdmin.Browser.notifier.success(gettext('Role Saved Successfully')); + resolve(); + onClose(null, true); + }) + .catch((err)=>{ + reject(err instanceof Error ? err : Error(gettext('Something went wrong'))); + }); + } catch (error) { + reject(Error(parseApiError(error))); + } + }); + }; + + return + { return Promise.resolve(role); }} + schema={schema} + viewHelperProps={{ + mode: isEdit ? 'edit' : 'create', + }} + onSave={onSaveClick} + onClose={onClose} + hasSQL={false} + disableSqlHelp={true} + disableDialogHelp={true} + isTabView={false} + /> + ; +} + +RoleDialog.propTypes = { + role: PropTypes.object, + onClose: PropTypes.func, +}; diff --git a/web/pgadmin/tools/user_management/static/js/Roles.jsx b/web/pgadmin/tools/user_management/static/js/Roles.jsx new file mode 100644 index 000000000..c9fc93bdf --- /dev/null +++ b/web/pgadmin/tools/user_management/static/js/Roles.jsx @@ -0,0 +1,177 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2025, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import React, { useMemo } from 'react'; +import gettext from 'sources/gettext'; +import PgTable from '../../../../static/js/components/PgTable'; +import { getDeleteCell, getEditCell } from '../../../../static/js/components/PgReactTableStyled'; +import RoleDialog from './RoleDialog'; +import Loader from 'sources/components/Loader'; + +import getApiInstance, { parseApiError } from '../../../../static/js/api_instance'; +import url_for from 'sources/url_for'; +import { BROWSER_PANELS } from '../../../../browser/static/js/constants'; +import ErrorBoundary from '../../../../static/js/helpers/ErrorBoundary'; +import { Box } from '@mui/material'; +import {Add as AddIcon, SyncRounded, Help as HelpIcon} from '@mui/icons-material'; +import PropTypes from 'prop-types'; +import { PgButtonGroup, PgIconButton } from '../../../../static/js/components/Buttons'; +import { usePgAdmin } from '../../../../static/js/PgAdminProvider'; + +function CustomHeader({updateRoles, pgAdmin}) { + return ( + + + } + aria-label="Create Role" + title={gettext('Create Role...')} + onClick={() => { + const panelTitle = gettext('Create Role'); + const panelId = BROWSER_PANELS.USER_MANAGEMENT + '-new-role'; + pgAdmin.Browser.docker.default_workspace.openDialog({ + id: panelId, + title: panelTitle, + content: ( + + { + pgAdmin.Browser.docker.default_workspace.close(panelId, true); + reload && updateRoles(); + }} + /> + + ) + }, pgAdmin.Browser.stdW.md, pgAdmin.Browser.stdH.md); + }} + > + } + aria-label="Refresh" + title={gettext('Refresh')} + onClick={updateRoles} + > + } + aria-label="Help" + title={gettext('Help')} + onClick={() => { + window.open(url_for('help.static', { 'filename': 'user_management.html' })); + }} + > + + + ); +} +CustomHeader.propTypes = { + updateRoles: PropTypes.func, + pgAdmin: PropTypes.object, +}; + +export default function Roles({roles, updateRoles}) { + const [loading, setLoading] = React.useState(''); + const api = getApiInstance(); + const pgAdmin = usePgAdmin(); + + const onDeleteClick = (row) => { + pgAdmin.Browser.notifier.confirm(gettext('Delete Role'), gettext('Are you sure you want to delete the role %s?', row.original.name), + async () => { + setLoading(gettext('Deleting role...')); + try { + await api.delete(url_for('user_management.role_delete', { id: row.original.id })); + pgAdmin.Browser.notifier.success(gettext('Role deleted successfully.')); + updateRoles(); + } catch (error) { + pgAdmin.Browser.notifier.error(parseApiError(error)); + } + setLoading(''); + }); + }; + + const onEditClick = (row) => { + const role = row.original; + const panelTitle = gettext('Edit Role - %s', role.name); + const panelId = BROWSER_PANELS.USER_MANAGEMENT + '-edit-role' + role.id; + pgAdmin.Browser.docker.default_workspace.openDialog({ + id: panelId, + title: panelTitle, + content: ( + + { + pgAdmin.Browser.docker.default_workspace.close(panelId, true); + reload && updateRoles(); + }} + /> + + ) + }, pgAdmin.Browser.stdW.md, pgAdmin.Browser.stdH.md); + }; + + const columns = useMemo(() => [{ + header: () => null, + enableSorting: false, + enableResizing: false, + enableFilters: false, + size: 35, + maxSize: 35, + minSize: 35, + id: 'btn-delete', + cell: getDeleteCell({ title: gettext('Delete Role'), onClick: onDeleteClick, isDisabled: (row) => row.original.is_admin }), + },{ + header: () => null, + enableSorting: false, + enableResizing: false, + enableFilters: false, + size: 35, + maxSize: 35, + minSize: 35, + id: 'btn-edit', + cell: getEditCell({ title: gettext('Edit Role'), onClick: onEditClick, isDisabled: (row) => row.original.is_admin }), + }, + { + header: gettext('Name'), + accessorKey: 'name', + size: 50, + minSize: 50, + }, + { + header: gettext('Decscription'), + accessorKey: 'description', + size: 100, + minSize: 100, + }], []); + + return ( + + + { + return row.id; + } + }} + customHeader={} + > + + ); +} + +Roles.propTypes = { + roles: PropTypes.array, + updateRoles: PropTypes.func, +}; diff --git a/web/pgadmin/tools/user_management/static/js/Users.jsx b/web/pgadmin/tools/user_management/static/js/Users.jsx index 93ff905c1..1d6083774 100644 --- a/web/pgadmin/tools/user_management/static/js/Users.jsx +++ b/web/pgadmin/tools/user_management/static/js/Users.jsx @@ -10,7 +10,6 @@ import React, { useEffect, useMemo, useRef } from 'react'; import { getDeleteCell, getEditCell, getSwitchCell } from '../../../../static/js/components/PgReactTableStyled'; import gettext from 'sources/gettext'; -import pgAdmin from 'sources/pgadmin'; import getApiInstance, { parseApiError } from '../../../../static/js/api_instance'; import PgTable from 'sources/components/PgTable'; import url_for from 'sources/url_for'; @@ -24,8 +23,9 @@ import PropTypes from 'prop-types'; import { PgButtonGroup, PgIconButton } from '../../../../static/js/components/Buttons'; import { showChangeOwnership } from '../../../../static/js/Dialogs'; import { isEmptyString } from '../../../../static/js/validators'; +import { usePgAdmin } from '../../../../static/js/PgAdminProvider'; -function CustomHeader({updateUsers, options}) { +function CustomHeader({updateUsers, options, pgAdmin}) { return ( @@ -77,15 +77,15 @@ function CustomHeader({updateUsers, options}) { CustomHeader.propTypes = { updateUsers: PropTypes.func, options: PropTypes.object, + pgAdmin: PropTypes.object, }; -export default function Users() { +export default function Users({roles}) { const authSources = useRef([]); - const roles = useRef([]); const [loading, setLoading] = React.useState(''); const [tableData, setTableData] = React.useState([]); - const [selectedRows, setSelectedRows] = React.useState({}); const api = getApiInstance(); + const pgAdmin = usePgAdmin(); const onDeleteClick = (row) => { const deleteRow = async () => { @@ -144,6 +144,7 @@ export default function Users() { const user = row.original; const panelTitle = gettext('Edit User - %s', user.username); const panelId = BROWSER_PANELS.USER_MANAGEMENT + '-edit-' + user.id; + pgAdmin.Browser.docker.default_workspace.openDialog({ id: panelId, title: panelTitle, @@ -152,7 +153,7 @@ export default function Users() { ({ label: s.label, value: s.value })), - roles: roles.current.map((r) => ({ label: r.name, value: r.id })), + roles: roles.map((r) => ({ label: r.name, value: r.id })), }} user={user} onClose={(_e, reload) => { @@ -216,7 +217,7 @@ export default function Users() { }, { header: gettext('Role'), - accessorFn: (row) => roles.current.find((r)=>r.id == row.role).name, + accessorFn: (row) => roles.find((r)=>r.id == row.role)?.name, enableSorting: true, enableResizing: true, size: 100, @@ -243,7 +244,7 @@ export default function Users() { enableFilters: true, cell: getSwitchCell(), }]; - }, []); + }, [roles]); const updateList = async () => { setLoading(gettext('Fetching users...')); @@ -259,12 +260,8 @@ export default function Users() { const initialize = async () => { setLoading(gettext('Loading...')); try { - const res = await Promise.all([ - api.get(url_for('user_management.auth_sources')), - api.get(url_for('user_management.roles')), - ]); - authSources.current = res[0].data; - roles.current = res[1].data; + const res = await api.get(url_for('user_management.auth_sources')); + authSources.current = res.data; updateList(); } catch (error) { setLoading(''); @@ -284,8 +281,6 @@ export default function Users() { columns={columns} data={tableData} sortOptions={[{ id: 'username', desc: true }]} - selectedRows={selectedRows} - setSelectedRows={setSelectedRows} caveTable={false} tableNoBorder={false} tableProps={{ @@ -295,9 +290,13 @@ export default function Users() { }} customHeader={ ({ label: s.label, value: s.value })), - roles: roles.current.map((r) => ({ label: r.name, value: r.id })), - }} />} + roles: roles.map((r) => ({ label: r.name, value: r.id })), + }} pgAdmin={pgAdmin} />} > ); -} \ No newline at end of file +} + +Users.propTypes = { + roles: PropTypes.array, +}; diff --git a/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js b/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js index 31d38aebb..70fc98241 100644 --- a/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js +++ b/web/pgadmin/tools/user_management/templates/user_management/js/current_user.js @@ -16,6 +16,7 @@ define('pgadmin.user_management.current_user', [], function() { 'allow_save_password': {{ allow_save_password }}, 'allow_save_tunnel_password': {{ allow_save_tunnel_password }}, 'auth_sources': {{ auth_sources }}, - 'current_auth_source': '{{ current_auth_source }}' + 'current_auth_source': '{{ current_auth_source }}', + 'permissions': {{ permissions }} } }); diff --git a/web/regression/javascript/fake_endpoints.js b/web/regression/javascript/fake_endpoints.js index e9ab2ec27..3b7d91d40 100644 --- a/web/regression/javascript/fake_endpoints.js +++ b/web/regression/javascript/fake_endpoints.js @@ -42,4 +42,6 @@ module.exports = { 'user_management.auth_sources': '/user_management/auth_sources', 'user_management.roles': '/user_management/roles', 'user_management.users': '/user_management/users', + 'user_management.all_permissions': '/user_management/all_permissions', + 'user_management.save_permissions': '/user_management/save_permissions', }; diff --git a/web/regression/javascript/user_management/Permissions.spec.js b/web/regression/javascript/user_management/Permissions.spec.js new file mode 100644 index 000000000..3bf8885d8 --- /dev/null +++ b/web/regression/javascript/user_management/Permissions.spec.js @@ -0,0 +1,113 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2025, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + + +import React from 'react'; + +import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'; +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { withBrowser } from '../genericFunctions'; +import Permissions from '../../../pgadmin/tools/user_management/static/js/Permissions'; +import { withTheme } from '../fake_theme'; + +describe('Permissions Component', () => { + let networkMock; + let ctrl; + const PermissionsWithBrowser = withBrowser(withTheme(Permissions)); + const mockRoles = [ + { id: 1, name: 'Administrator', permissions: [] }, + { id: 2, name: 'User', permissions: ['p1', 'p2', 'p3'] }, + { id: 3, name: 'Other', permissions: ['p1', 'p2'] }, + ]; + const mockPermissions = [ + { name: 'p1', label: 'Permission 1', category: 'Category 1' }, + { name: 'p2', label: 'Permission 2', category: 'Category 1' }, + { name: 'p3', label: 'Permission 3', category: 'Category 2' }, + ]; + const mockUpdateRolePermissions = jest.fn(); + + const renderComponent = async () => { + await act( async () => { + if(ctrl) { + ctrl.unmount(); + } + ctrl = render( + + ); + }); + }; + + beforeEach(async ()=>{ + networkMock = new MockAdapter(axios); + networkMock.onGet('/user_management/all_permissions').reply(200, mockPermissions); + + await renderComponent(); + }); + + afterEach(() => { + networkMock.restore(); + }); + + it('renders the component and loads permissions', async () => { + expect(screen.getByText('Role')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument(); + }); + + it('allows selecting a role and displays permissions', async () => { + fireEvent.focus(screen.getByRole('combobox')); + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown', code: 40 }); + fireEvent.click(screen.getByText('Other')); + await waitFor(() => { + expect(screen.getByText('Category 1')).toBeInTheDocument(); + expect(screen.getByText('Permission 1')).toBeInTheDocument(); + expect(screen.getByText('Permission 2')).toBeInTheDocument(); + }); + }); + + it('filters permissions based on search input', async () => { + fireEvent.focus(screen.getByRole('combobox')); + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown', code: 40 }); + fireEvent.click(screen.getByText('Other')); + fireEvent.change(screen.getByPlaceholderText('Search'), { target: { value: 'Permission 3' } }); + await waitFor(() => { + expect(screen.getByText('Permission 3')).toBeInTheDocument(); + expect(screen.queryByText('Permission 1')).not.toBeInTheDocument(); + }); + }); + + it('saves permissions', async () => { + fireEvent.focus(screen.getByRole('combobox')); + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown', code: 40 }); + fireEvent.click(screen.getByText('Other')); + fireEvent.click(screen.getByText('Permission 3')); + networkMock.onPut('/user_management/save_permissions').reply(200, { + permissions: ['p1', 'p2', 'p3'] + }); + + await waitFor(() => { + expect(screen.getByText('Save')).not.toBeDisabled(); + }); + + await act(async () => { + fireEvent.click(screen.getByText('Save')); + mockRoles[2].permissions = ['p1', 'p2', 'p3']; + }); + + await renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Save')).toBeDisabled(); + expect(mockUpdateRolePermissions).toHaveBeenCalledWith(3, ['p1', 'p2', 'p3']); + }); + }); +}); diff --git a/web/regression/javascript/user_management/Roles.spec.js b/web/regression/javascript/user_management/Roles.spec.js new file mode 100644 index 000000000..e2881090a --- /dev/null +++ b/web/regression/javascript/user_management/Roles.spec.js @@ -0,0 +1,67 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2025, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import React from 'react'; + +import { act, render } from '@testing-library/react'; +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { withBrowser } from '../genericFunctions'; +import Roles from '../../../pgadmin/tools/user_management/static/js/Roles'; + +describe('Roles', () => { + let networkMock; + + beforeEach(() => { + networkMock = new MockAdapter(axios); + networkMock.onGet('/user_management/auth_sources').reply(200, [ + { id: 1, label: 'internal', value: 'internal' }, + ]); + networkMock.onGet('/user_management/roles').reply(200, [ + { id: 1, name: 'Administrator' }, + { id: 2, name: 'User' }, + ]); + networkMock.onGet('/user_management/users').reply(200, [ + { id: 1, label: 'postgres', value: 'postgres', auth_source: 'internal', role: 1 }, + ]); + }); + + afterEach(() => { + networkMock.restore(); + }); + + describe('Component', () => { + const RolesWithBrowser = withBrowser(Roles); + + it('init', async () => { + let ctrl; + await act(() => { + ctrl = render(); + }); + expect(ctrl.container.querySelectorAll('[data-test="roles"]').length).toBe(1); + }); + + it('renders role list', async () => { + let ctrl; + await act(() => { + ctrl = render(); + }); + const roleItems = ctrl.container.querySelectorAll('.pgrt-row-content '); + expect(roleItems.length).toBe(2); + expect(roleItems[0].querySelector('.pgrd-row-cell:not(.btn-cell) .pgrd-row-cell-content').textContent).toContain('Administrator'); + expect(roleItems[1].querySelector('.pgrd-row-cell:not(.btn-cell) .pgrd-row-cell-content').textContent).toContain('User'); + }); + }); +}); diff --git a/web/regression/javascript/user_management/Users.spec.js b/web/regression/javascript/user_management/Users.spec.js index 11f7b1b60..1dd1b520c 100644 --- a/web/regression/javascript/user_management/Users.spec.js +++ b/web/regression/javascript/user_management/Users.spec.js @@ -44,8 +44,11 @@ describe('Users', ()=>{ it('init', async ()=>{ let ctrl; - await act(async ()=>{ - ctrl = await render(); + await act(()=>{ + ctrl = render(); }); expect(ctrl.container.querySelectorAll('[data-test="users"]').length).toBe(1); }); diff --git a/web/setup.py b/web/setup.py index 947802cfc..a125d9ec9 100644 --- a/web/setup.py +++ b/web/setup.py @@ -37,7 +37,7 @@ if 'SERVER_MODE' in globals(): else: builtins.SERVER_MODE = None -from pgadmin.model import db, Version, User, \ +from pgadmin.model import db, Version, User, Role, \ SCHEMA_VERSION as CURRENT_SCHEMA_VERSION from pgadmin import create_app from pgadmin.utils import clear_database_servers, dump_database_servers, \ @@ -148,13 +148,27 @@ class AuthType(str, Enum): internal = INTERNAL +class ManageRoles: + @staticmethod + def get_role(role: str): + app = create_app(config.APP_NAME + '-cli') + usr = None + with app.test_request_context(): + usr = Role.query.filter_by(name=role).first() + + if not usr: + return None + return usr.id + + class ManageUsers: @app.command() @update_sqlite_path def add_user(email: str, password: str, - role: Annotated[Optional[bool], typer.Option( - "--admin/--nonadmin")] = False, + admin: Annotated[Optional[bool], + typer.Option("--admin")] = False, + role: Optional[str] = None, active: Annotated[Optional[bool], typer.Option("--active/--inactive")] = True, console: Optional[bool] = True, @@ -165,7 +179,7 @@ class ManageUsers: data = { 'email': email, - 'role': 1 if role else 2, + 'role': 'Administrator' if admin else role, 'active': active, 'auth_source': INTERNAL, 'newPassword': password, @@ -178,9 +192,9 @@ class ManageUsers: def add_external_user(username: str, auth_source: AuthExtTypes = AuthExtTypes.oauth2, email: Optional[str] = None, - role: Annotated[Optional[bool], - typer.Option( - "--admin/--nonadmin")] = False, + admin: Annotated[Optional[bool], + typer.Option("--admin")] = False, + role: Optional[str] = None, active: Annotated[Optional[bool], typer.Option( "--active/--inactive")] = True, @@ -194,7 +208,7 @@ class ManageUsers: data = { 'username': username, 'email': email, - 'role': 1 if role else 2, + 'role': 'Administrator' if admin else role, 'active': active, 'auth_source': auth_source } @@ -231,9 +245,9 @@ class ManageUsers: @update_sqlite_path def update_user(email: str, password: Optional[str] = None, - role: Annotated[Optional[bool], - typer.Option("--admin/--nonadmin" - )] = None, + admin: Annotated[Optional[bool], typer.Option( + "--admin")] = False, + role: Optional[str] = None, active: Annotated[Optional[bool], typer.Option("--active/--inactive" )] = None, @@ -254,12 +268,21 @@ class ManageUsers: data['confirmPassword'] = password if role is not None: - data['role'] = 1 if role else 2 + data['role'] = role + if admin: + data['role'] = 'Administrator' if active is not None: data['active'] = active app = create_app(config.APP_NAME + '-cli') with app.test_request_context(): + rid = ManageRoles.get_role(data['role']) + if rid is None: + print("Role '{0}' does not exists.".format(data['role'])) + exit() + + data['role'] = rid + uid = ManageUsers.get_user(username=email, auth_source=INTERNAL) if not uid: @@ -308,7 +331,7 @@ class ManageUsers: 'username': u.username, 'email': u.email, 'active': u.active, - 'role': u.roles[0].id, + 'role': u.roles[0].name, 'auth_source': u.auth_source, 'locked': u.locked } @@ -323,9 +346,9 @@ class ManageUsers: def update_external_user(username: str, auth_source: AuthExtTypes = AuthExtTypes.oauth2, email: Optional[str] = None, - role: Annotated[Optional[bool], - typer.Option("--admin/--nonadmin" - )] = None, + admin: Annotated[Optional[bool], typer.Option( + "--admin")] = False, + role: Optional[str] = None, active: Annotated[ Optional[bool], typer.Option("--active/--inactive")] = None, @@ -340,12 +363,21 @@ class ManageUsers: if email: data['email'] = email if role is not None: - data['role'] = 1 if role else 2 + data['role'] = role + if admin: + data['role'] = 'Administrator' if active is not None: data['active'] = active app = create_app(config.APP_NAME + '-cli') with app.test_request_context(): + rid = ManageRoles.get_role(data['role']) + if rid is None: + print("Role '{0}' does not exists.".format(data['role'])) + exit() + + data['role'] = rid + uid = ManageUsers.get_user(username=username, auth_source=auth_source) if not uid: @@ -367,6 +399,14 @@ class ManageUsers: with app.test_request_context(): username = data['username'] if 'username' in data else \ data['email'] + + rid = ManageRoles.get_role(data['role']) + if rid is None: + print("Role '{0}' does not exists.".format(data['role'])) + exit() + + data['role'] = rid + uid = ManageUsers.get_user(username=username, auth_source=data['auth_source']) if uid: @@ -379,7 +419,10 @@ class ManageUsers: status, msg = create_user(data) if status: - ManageUsers.display_user(data, console, json) + _user = ManageUsers.get_users_from_db( + username=data['email'], + auth_source=data['auth_source'], + console=console) else: print(SOMETHING_WENT_WRONG + str(msg)) @@ -412,10 +455,7 @@ class ManageUsers: if 'email' in _data: table.add_row("Email", _data['email']) table.add_row("auth_source", _data['auth_source']) - table.add_row("role", - "Admin" if _data['role'] and - _data['role'] != 2 else - "Non-admin") + table.add_row("role", _data['role']) table.add_row("active", 'True' if _data['active'] else 'False') console.print(table)