Skip to content

Commit 1a65d09

Browse files
Add support for custom roles and role permissions management in pgAdmin. #7310
1 parent 627aa5d commit 1a65d09

File tree

53 files changed

+1236
-137
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+1236
-137
lines changed

docs/en_US/images/add_role.png

26 KB
Loading

docs/en_US/images/permissions.png

87.2 KB
Loading

docs/en_US/images/roles.png

45.9 KB
Loading

docs/en_US/images/user.png

-70.4 KB
Binary file not shown.

docs/en_US/images/users.png

62.4 KB
Loading

docs/en_US/user_management.rst

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ When you authenticate with pgAdmin, the server definitions associated with that
1212
login role are made available in the tree control.
1313

1414
Users Tab
15-
*******************
15+
*********
1616
An administrative user can use the *Users* tab to:
1717

1818
* manage pgAdmin users
@@ -21,7 +21,7 @@ An administrative user can use the *Users* tab to:
2121
* deactivate user
2222
* unlock a locked user
2323

24-
.. image:: images/user.png
24+
.. image:: images/users.png
2525
:alt: pgAdmin user management window
2626
:align: center
2727

@@ -78,6 +78,60 @@ users, but otherwise have the same capabilities as those with the *User* role.
7878
* Click the *Help* button (?) to access online help.
7979

8080

81+
Roles Tab
82+
*********
83+
An administrative user can use the *Roles* tab to:
84+
85+
* manage pgAdmin roles
86+
* delete roles
87+
88+
.. image:: images/roles.png
89+
:alt: pgAdmin roles management window
90+
:align: center
91+
92+
Use the *Search* field to specify criteria and review a list of roles
93+
that match the specified criteria. You can enter a value that matches
94+
the following criteria types: *Role Name* or *Description*.
95+
96+
To add a role, click the Add (+) button at the top left corner. It will open a
97+
dialog where you can fill in details for the new role.
98+
99+
.. image:: images/add_role.png
100+
:alt: pgAdmin roles management window add new role
101+
:align: center
102+
103+
Provide information about the new pgAdmin role in the row:
104+
105+
* Use the *Role Name* field to specify a unique name for the role.
106+
* Use the *Description* field to provide a brief description of the role.
107+
108+
To delete a role, click the trash icon to the left of the row and confirm deletion
109+
in the *Delete role?* dialog. If the role is associated with any users or resources,
110+
you may need to reassign those associations before deletion.
111+
112+
Roles allow administrators to group privileges and assign them to users more efficiently.
113+
This helps in managing permissions and access control within the pgAdmin client.
114+
115+
* Click the *Refresh* button to get the latest roles list.
116+
* Click the *Help* button (?) to access online help.
117+
118+
119+
Permissions Tab
120+
***************
121+
An administrative user can use the *Permissions* tab to manage pgAdmin permissions for
122+
a role.
123+
124+
.. image:: images/permissions.png
125+
:alt: pgAdmin permissions management window
126+
:align: center
127+
128+
* Filter permissions using the *Search* field by entering names that match the list.
129+
* Administrators can select permissions from the list of available permissions, and
130+
choose to grant or revoke these permissions for specific roles.
131+
* The permissions are applied to the selected role immediately.
132+
133+
134+
81135
Using 'setup.py' command line script
82136
####################################
83137

web/migrations/versions/1f0eddc8fc79_.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ def upgrade():
3030
sa.Column('db_res_type', sa.String(length=32),
3131
server_default=RESTRICTION_TYPE_DATABASES))
3232

33+
# For adding custom role permissions
34+
op.add_column('role', sa.Column('permissions', sa.Text()))
35+
36+
# get metadata from current connection
37+
meta = sa.MetaData()
38+
# define table representation
39+
meta.reflect(op.get_bind(), only=('role',))
40+
role_table = sa.Table('role', meta)
41+
42+
from pgadmin.tools.user_management.PgPermissions import AllPermissionTypes
43+
op.execute(
44+
role_table.update().where(role_table.c.name == 'User')
45+
.values(permissions=",".join(AllPermissionTypes.__dict__.keys())))
46+
3347

3448
def downgrade():
3549
# pgAdmin only upgrades, downgrade not implemented.

web/pgadmin/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,8 @@ def get_locale():
349349
app.config['SECURITY_MSG_INVALID_PASSWORD'] = \
350350
(gettext("Incorrect username or password."), "error")
351351
app.config['SECURITY_PASSWORD_LENGTH_MIN'] = config.PASSWORD_LENGTH_MIN
352+
app.config['SECURITY_MSG_UNAUTHORIZED'] = \
353+
(gettext("You do not have permission to this resource."), "error")
352354

353355
# Create database connection object and mailer
354356
db.init_app(app)

web/pgadmin/browser/server_groups/servers/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from flask import render_template, request, make_response, jsonify, \
1414
current_app, url_for, session
1515
from flask_babel import gettext
16-
from flask_security import current_user
16+
from flask_security import current_user, permissions_required
1717
from pgadmin.user_login_check import pga_login_required
1818
from psycopg.conninfo import make_conninfo, conninfo_to_dict
1919

@@ -24,6 +24,7 @@
2424
from pgadmin.utils.crypto import encrypt, decrypt, pqencryptpassword
2525
from pgadmin.utils.menu import MenuItem
2626
from pgadmin.tools.sqleditor.utils.query_history import QueryHistory
27+
from pgadmin.tools.user_management.PgPermissions import AllPermissionTypes
2728

2829
import config
2930
from config import PG_DEFAULT_DRIVER
@@ -1081,6 +1082,7 @@ def update_connection_string(manager, server):
10811082
display_conn_string = make_conninfo(**con_info_ord)
10821083
return display_conn_string
10831084

1085+
@permissions_required(AllPermissionTypes.object_register_server)
10841086
@pga_login_required
10851087
def create(self, gid):
10861088
"""Add a server node to the settings database"""

web/pgadmin/browser/server_groups/servers/static/js/server.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ define('pgadmin.node.server', [
8181
name: 'create_server_on_sg', node: 'server_group', module: this,
8282
applies: ['object', 'context'], callback: 'show_obj_properties',
8383
category: 'register', priority: 1, label: gettext('Server...'),
84-
data: {action: 'create'}, enable: 'canCreate',
84+
data: {action: 'create'}, enable: 'canCreate', permission: 'object_register_server'
8585
},{
8686
name: 'disconnect_all_servers', node: 'server_group', module: this,
8787
applies: ['object','context'], callback: 'disconnect_all_servers',
@@ -91,7 +91,7 @@ define('pgadmin.node.server', [
9191
name: 'create_server', node: 'server', module: this,
9292
applies: ['object', 'context'], callback: 'show_obj_properties',
9393
category: 'register', priority: 3, label: gettext('Server...'),
94-
data: {action: 'create'}, enable: 'canCreate',
94+
data: {action: 'create'}, enable: 'canCreate', permission: 'object_register_server'
9595
},{
9696
name: 'connect_server', node: 'server', module: this,
9797
applies: ['object', 'context'], callback: 'connect_server',

web/pgadmin/browser/static/js/MainMenuFactory.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import pgAdmin from 'sources/pgadmin';
1111
import Menu, { MenuItem } from '../../../static/js/helpers/Menu';
1212
import getApiInstance from '../../../static/js/api_instance';
1313
import url_for from 'sources/url_for';
14+
import withCheckPermission from './withCheckPermission';
1415

1516
const MAIN_MENUS = [
1617
{ label: gettext('File'), name: 'file', id: 'mnu_file', index: 0, addSeprator: true, hasDynamicMenuItems: false },
@@ -71,7 +72,7 @@ export default class MainMenuFactory {
7172
}
7273

7374
static createMenuItem(options) {
74-
return new MenuItem({...options, callback: () => {
75+
const callback = () => {
7576
// Some callbacks registered in 'callbacks' check and call specifiec callback function
7677
if (options.module && 'callbacks' in options.module && options.module.callbacks[options.callback]) {
7778
options.module.callbacks[options.callback].apply(options.module, [options.data, pgAdmin.Browser.tree?.selected()]);
@@ -89,7 +90,8 @@ export default class MainMenuFactory {
8990
pgAdmin.Browser.notifier.error(gettext('Error in opening window'));
9091
});
9192
}
92-
}}, (menu, item)=> {
93+
};
94+
return new MenuItem({...options, callback: withCheckPermission(options, callback)}, (menu, item)=> {
9395
pgAdmin.Browser.Events.trigger('pgadmin:enable-disable-menu-items', menu, item);
9496
window.electronUI?.enableDisableMenuItems(menu?.serialize(), item?.serialize());
9597
});

web/pgadmin/browser/static/js/browser.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ define('pgadmin.browser', [
391391
checked: _m.checked,
392392
below: _m.below,
393393
applies: _m.applies,
394+
permission: _m.permission,
394395
};
395396
};
396397

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import pgAdmin from 'sources/pgadmin';
2+
import current_user from 'pgadmin.user_management.current_user';
3+
import gettext from 'sources/gettext';
4+
5+
export default function withCheckPermission(options, callback) {
6+
// Check if the user has permission to access the menu item
7+
return ()=>{
8+
if(!options.permission || (options.permission && current_user.permissions?.includes(options.permission))) {
9+
callback();
10+
} else {
11+
pgAdmin.Browser.notifier.alert(
12+
gettext('Permission Denied'),
13+
gettext('You do not have permission to access this menu item.')
14+
);
15+
}
16+
};
17+
}

web/pgadmin/dashboard/static/js/components/SectionContainer.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const StyledBox = styled(Box)(({theme}) => ({
2929
'& .SectionContainer-cardTitle': {
3030
padding: '0.25rem 0.5rem',
3131
fontWeight: 'bold',
32+
width: '100%',
3233
}
3334
},
3435
}));
@@ -50,7 +51,7 @@ export default function SectionContainer({title, titleExtras, children, style})
5051
}
5152

5253
SectionContainer.propTypes = {
53-
title: PropTypes.string.isRequired,
54+
title: PropTypes.any.isRequired,
5455
titleExtras: PropTypes.node,
5556
children: PropTypes.node.isRequired,
5657
style: PropTypes.object,

web/pgadmin/misc/file_manager/__init__.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from pgadmin.utils.constants import PREF_LABEL_OPTIONS, MIMETYPE_APP_JS, \
3636
MY_STORAGE
3737
from pgadmin.settings.utils import get_file_type_setting
38+
from pgadmin.tools.user_management.PgPermissions import AllPermissionTypes
3839

3940
# Checks if platform is Windows
4041
if _platform == "win32":
@@ -345,6 +346,20 @@ def get_closest_parent(storage_dir, last_dir):
345346

346347
return last_dir
347348

349+
@staticmethod
350+
def check_capability_permission(capability):
351+
"""
352+
Check if the user has permission for the capability
353+
"""
354+
if capability == 'create':
355+
return current_user.has_permission(
356+
AllPermissionTypes.storage_add_folder)
357+
elif capability == 'delete':
358+
return current_user.has_permission(
359+
AllPermissionTypes.storage_remove_folder)
360+
361+
return True
362+
348363
@staticmethod
349364
def create_new_transaction(params):
350365
"""
@@ -366,16 +381,20 @@ def create_new_transaction(params):
366381
show_volumes = isinstance(storage_dir, list) or not storage_dir
367382
supp_types = allow_upload_files = params.get('supported_types', [])
368383

384+
allow_folder_create = ['create'] if \
385+
Filemanager.check_capability_permission('create') else []
386+
allow_folder_delete = ['delete'] if \
387+
Filemanager.check_capability_permission('delete') else []
369388
# tuples with (capabilities, files_only, folders_only, title)
370389
capability_map = {
371390
'select_file': (
372-
['select_file', 'rename', 'upload', 'delete'],
391+
['select_file', 'rename', 'upload'] + allow_folder_delete,
373392
True,
374393
False,
375394
gettext("Select File")
376395
),
377396
'select_folder': (
378-
['select_folder', 'rename', 'create'],
397+
['select_folder', 'rename'] + allow_folder_create,
379398
False,
380399
True,
381400
gettext("Select Folder")
@@ -387,14 +406,14 @@ def create_new_transaction(params):
387406
gettext("Select File")
388407
),
389408
'create_file': (
390-
['select_file', 'rename', 'create'],
409+
['select_file', 'rename'] + allow_folder_create,
391410
True,
392411
False,
393412
gettext("Create File")
394413
),
395414
'storage_dialog': (
396-
['select_folder', 'select_file', 'download',
397-
'rename', 'delete', 'upload', 'create'],
415+
['select_folder', 'select_file', 'download', 'rename',
416+
'upload'] + allow_folder_delete + allow_folder_create,
398417
True,
399418
False,
400419
gettext("Storage Manager")
@@ -767,7 +786,9 @@ def validate_request(self, capability):
767786
stored in the session
768787
"""
769788
trans_data = Filemanager.get_trasaction_selection(self.trans_id)
770-
return False if capability not in trans_data['capabilities'] else True
789+
# capability
790+
return False if capability not in trans_data['capabilities'] \
791+
else Filemanager.check_capability_permission(capability)
771792

772793
def getfolder(self, path=None, file_type="", show_hidden=False):
773794
"""

web/pgadmin/misc/file_manager/static/js/components/FileManager.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -873,4 +873,4 @@ FileManager.propTypes = {
873873
onCancel: PropTypes.func,
874874
sharedStorages: PropTypes.array,
875875
restrictedSharedStorage: PropTypes.array,
876-
};
876+
};

web/pgadmin/model/__init__.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,29 @@
5959
)
6060

6161

62+
class PgAdminDbArrayString(types.TypeDecorator):
63+
cache_ok = True
64+
impl = types.String
65+
66+
def process_bind_param(self, value, dialect):
67+
try:
68+
if len(value) == 0:
69+
return None
70+
71+
return ",".join(value)
72+
except Exception as _:
73+
return None
74+
75+
def process_result_value(self, value, dialect):
76+
try:
77+
if value == '':
78+
return []
79+
80+
return value.split(',')
81+
except Exception as _:
82+
return []
83+
84+
6285
class PgAdminDbBinaryString(types.TypeDecorator):
6386
"""
6487
To make binary string storing compatible with both
@@ -92,6 +115,28 @@ class Role(db.Model, RoleMixin):
92115
id = db.Column(db.Integer(), primary_key=True)
93116
name = db.Column(db.String(128), unique=True, nullable=False)
94117
description = db.Column(db.String(256), nullable=False)
118+
# permissions needs to be an array, use custom type to support
119+
# both SQLite and PostgreSQL
120+
permissions = db.Column(PgAdminDbArrayString())
121+
122+
def get_permissions(self):
123+
from pgadmin.tools.user_management.PgPermissions \
124+
import AllPermissionTypes
125+
if self.name == 'Administrator':
126+
return filter(lambda x: not x.startswith('_'),
127+
AllPermissionTypes.__dict__.keys())
128+
129+
return super().get_permissions()
130+
131+
132+
# We override the default UserMixin to change behaviour of has_permission
133+
# Administrator has all permissions
134+
class CustomUserMixin(UserMixin):
135+
def has_permission(self, permission: str) -> bool:
136+
if 'Administrator' in self.roles:
137+
return True
138+
139+
return super().has_permission(permission)
95140

96141

97142
class User(db.Model, UserMixin):

web/pgadmin/static/js/components/FormComponents.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1280,7 +1280,8 @@ const StyledNotifierMessageBox = styled(Box)(({theme}) => ({
12801280
'& .FormFooter-message': {
12811281
color: theme.palette.text.primary,
12821282
marginLeft: theme.spacing(0.5),
1283-
whiteSpace: 'pre-line'
1283+
whiteSpace: 'pre-line',
1284+
userSelect: 'text'
12841285
},
12851286
'& .FormFooter-messageCenter': {
12861287
color: theme.palette.text.primary,

0 commit comments

Comments
 (0)