Restrict sharing a server to administrators in the server API and the servers.json import. #10651

The server dialog only shows the Shared switch to administrators, and
the servers.json import only honoured it for them, but the create and
update endpoints stored whatever 'shared' and 'shared_username' they
were sent. Any user able to register a server could therefore share one
with every other user of a server mode installation. The import had a
similar gap: validate_json_data() removed a non-administrator's "Shared"
entries only after its loop, so an early validation error left them in
the data, and the web path ignored that error, so selecting a shared
entry in a file that also held an invalid one created a shared server.

A non-administrator's request that would share a server is now refused
with a 403. A 'shared' value that changes nothing is still accepted, so
editing a private server is unaffected, and 'shared_username' is dropped
rather than refused because the dialog sends it whenever the username
changes. The import prunes a non-administrator's shared entries before
validating and skips them again for each imported entry, only a JSON
true counts as shared, and validation errors are reported on the web
path too. Copy Server no longer sends shared for a non-administrator
owner.

The existing servers are cleared for "replace" only after the file has
validated, so a refused file no longer deletes them. Servers shared by
non-administrators before the upgrade stay shared until the owner
un-shares them or an administrator changes them; pgAdmin warns about
them at server-mode startup, and the documentation describes this.

Compatibility: a servers.json import in server mode that fails
validation is now refused as a whole on the web path, as it already was
in setup.py.

Co-authored-by: Dave Page <dpage@pgadmin.org>
This commit is contained in:
KundanandDave Page authored and Ashesh Vashi committed 2026-10-24 19:36:12 +05:30
1 parent 2afe505ef4
commit d761942d0b
15 files changed
+916 -30

No files matched your search

+12
View File
@@ -33,6 +33,18 @@ Use the fields in the *General* tab to identify the server:
* If the *Shared?* switch is moved to *Yes* then that server can be
shared with all the other users. This option is available only to admin users.
For more information on users see :ref:`User Management Dialog <user_management>`.
Sharing a server, whether in this dialog, through the API or by importing a
servers.json file, is restricted to users with the *Administrator* role.
Servers that were shared by non-administrator users before this restriction
was introduced remain shared until they are un-shared, so administrators
should audit them: look for servers with the shared flag set in the
configuration database whose owner does not have the *Administrator* role.
pgAdmin logs a warning for each such server when it starts in server mode.
Such a server can be un-shared by its owner through the server API (a PUT
request on the server with ``"shared": false``), or by an administrator
changing the ``shared`` column of the server record in the pgAdmin
configuration database. The *Shared?* switch in this dialog is shown only
to administrators, and only the owner of a server can change it.
Users can access the shared servers with some restrictions - the following
operations on shared servers are not permitted:
+3
View File
@@ -557,6 +557,9 @@ def create_app(app_name=None):
from pgadmin.utils.passexec import server_passexec_startup
server_passexec_startup(app, cli_mode)
from pgadmin.utils import check_non_admin_shared_servers
check_non_admin_shared_servers(app.logger, cli_mode)
Mail(app)
# Don't bother paths when running in cli mode
@@ -74,6 +74,26 @@ def _is_non_owner(server):
return server.shared and server.user_id != current_user.id
def prepare_sharing_data(data, server=None):
"""Refuse a create or update payload sent to the servers API that would
share a server unless it comes from an administrator, matching the
server dialog. The servers.json import enforces the same rule
separately, in validate_json_data and load_database_servers.
Returns (data, errormsg); data is a plain dict copy.
A shared value that changes nothing, such as an unchanged false or a
non-owner's true, is let through. shared_username only matters for a
shared server, and the dialog can send it for anyone because it follows
the username field, so it is dropped rather than refused."""
data = dict(data)
if not config.SERVER_MODE or current_user.has_role('Administrator'):
return data, None
if data.get('shared') and not (server is not None and server.shared):
return data, gettext('Only an administrator can share a server.')
data.pop('shared_username', None)
return data, None
def prepare_passexec_data(data, is_non_owner):
"""Validate and normalise the password exec fields of a create or
update payload. Returns (data, errormsg); data is a plain dict copy,
@@ -1003,6 +1023,10 @@ class ServerNode(PGChildNodeView):
request.data
)
data, sharing_err = prepare_sharing_data(data, server)
if sharing_err:
return forbidden(errmsg=sharing_err)
data, passexec_err = prepare_passexec_data(
data, _is_non_owner(server))
if passexec_err:
@@ -1370,6 +1394,10 @@ class ServerNode(PGChildNodeView):
if data[item] == '':
data[item] = None
data, sharing_err = prepare_sharing_data(data)
if sharing_err:
return forbidden(errmsg=sharing_err)
data, passexec_err = prepare_passexec_data(data, False)
if passexec_err:
return make_json_response(
@@ -8,7 +8,7 @@
//////////////////////////////////////////////////////////////
import { getNodeListById } from '../../../../static/js/node_ajax';
import ServerSchema, { passexecForServerCopy } from './server.ui';
import ServerSchema, { passexecForServerCopy, sharingForServerCopy } from './server.ui';
import { showServerPassword, showChangeServerPassword, showNamedRestorePoint } from '../../../../../static/js/Dialogs/index';
import _ from 'lodash';
import getApiInstance, { parseApiError } from '../../../../../static/js/api_instance';
@@ -66,6 +66,9 @@ define('pgadmin.node.server', [
d.shared_username = null;
passexecForServerCopy(d);
}
// Only an administrator can share a server, so the copy made by
// anyone else is not shared.
sharingForServerCopy(d, current_user);
return d;
},
Init: function() {
@@ -58,6 +58,16 @@ export function passexecForServerCopy(d) {
return d;
}
// Only an administrator can share a server, so a copy made by anyone else
// is not shared, and does not carry the shared user name.
export function sharingForServerCopy(d, user) {
if (d?.shared && !user?.is_admin) {
d.shared = false;
d.shared_username = null;
}
return d;
}
export function getConnectionParameters() {
let conParams = [{
'value': 'hostaddr', 'label': gettext('Host address'), 'vartype': 'string'
@@ -0,0 +1,191 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
"""Only an administrator may share a server through the API, as through
the server dialog and the servers.json import."""
import json
import config
from pgadmin.model import db, Role, Server, User
from pgadmin.utils.route import BaseTestGenerator
from regression.python_test_utils import test_utils as utils
from .utils import restore_server_fixture_on_cleanup
from regression.test_setup import config_data
from regression.python_test_utils.test_utils import \
create_user_wise_test_client
test_user_details = None
if config.SERVER_MODE:
test_user_details = \
config_data['pgAdmin4_test_non_admin_credentials']
def _server_count(app):
with app.app_context():
return Server.query.count()
def _grant_user_role(test_case, email):
"""Give the test user the stock 'User' role, as a newly created user
would have, so that it may register servers; the test user is created
without any role."""
app = test_case.app
with app.app_context():
user = User.query.filter_by(email=email).first()
role = Role.query.filter_by(name='User').first()
if role in user.roles:
return
user.roles.append(role)
db.session.commit()
def revoke():
with app.app_context():
user = User.query.filter_by(email=email).first()
role = Role.query.filter_by(name='User').first()
if role in user.roles:
user.roles.remove(role)
db.session.commit()
test_case.addCleanup(revoke)
def _server_row(app, sid):
"""The fields of the server row these tests check."""
with app.app_context():
row = Server.query.filter_by(id=sid).first()
return dict(shared=bool(row.shared),
shared_username=row.shared_username,
comment=row.comment)
class NonAdminServerSharingTestCase(BaseTestGenerator):
"""A non-administrator cannot share a server, by creating or updating
it, but can still create and edit their own private servers."""
scenarios = [
('Non-admin cannot create a shared server',
dict(create={'shared': True}, expected_create=403)),
('Non-admin can create a server with shared false',
dict(create={'shared': False}, expected_create=200,
expected_shared=False)),
('Non-admin shared_username is ignored on create',
dict(create={'shared': False, 'shared_username': 'someone'},
expected_create=200, expected_shared=False,
expected_shared_username=None)),
('Non-admin cannot share their own server by update',
dict(create={}, expected_create=200,
update={'shared': True}, expected_update=403,
expected_shared=False)),
('Non-admin can edit their own server with an unchanged shared',
dict(create={}, expected_create=200,
update={'shared': False, 'comment': 'edited'},
expected_update=200, expected_shared=False,
expected_comment='edited')),
('Non-admin can edit their own server without shared',
dict(create={}, expected_create=200,
update={'comment': 'edited'},
expected_update=200, expected_shared=False,
expected_comment='edited')),
('Non-admin shared_username is ignored on update',
dict(create={}, expected_create=200,
update={'shared_username': 'someone', 'comment': 'edited'},
expected_update=200, expected_shared=False,
expected_shared_username=None, expected_comment='edited')),
]
update = None
expected_update = None
expected_shared = None
expected_shared_username = 'unchecked'
expected_comment = None
def setUp(self):
self.server_id = None
if not config.SERVER_MODE:
self.skipTest('Sharing only applies to server mode.')
restore_server_fixture_on_cleanup(self)
@create_user_wise_test_client(test_user_details)
def runTest(self):
_grant_user_role(self, test_user_details['login_username'])
server = dict(self.server)
server.pop('shared', None)
server.pop('shared_username', None)
server.update(self.create)
servers_before = _server_count(self.app)
response = self.tester.post(
'/browser/server/obj/{0}/'.format(utils.SERVER_GROUP),
data=json.dumps(server),
content_type='html/json'
)
self.assertEqual(response.status_code, self.expected_create,
response.data)
if self.expected_create != 200:
self.assertEqual(_server_count(self.app), servers_before)
return
self.server_id = json.loads(
response.data.decode('utf-8'))['node']['_id']
try:
if self.update is not None:
data = dict(self.update, id=self.server_id)
response = self.tester.put(
'/browser/server/obj/{0}/{1}'.format(
utils.SERVER_GROUP, self.server_id),
data=json.dumps(data),
content_type='html/json'
)
self.assertEqual(response.status_code,
self.expected_update, response.data)
row = _server_row(self.app, self.server_id)
self.assertEqual(row['shared'], self.expected_shared)
if self.expected_shared_username != 'unchecked':
self.assertEqual(row['shared_username'],
self.expected_shared_username)
if self.expected_comment is not None:
self.assertEqual(row['comment'], self.expected_comment)
finally:
utils.delete_server_with_api(self.tester, self.server_id)
self.server_id = None
class AdminServerSharingTestCase(BaseTestGenerator):
"""An administrator can still share a server, and name its user."""
scenarios = [
('Admin can create a shared server', dict()),
]
def setUp(self):
self.server_id = None
if not config.SERVER_MODE:
self.skipTest('Sharing only applies to server mode.')
restore_server_fixture_on_cleanup(self)
def runTest(self):
server = dict(self.server, shared=True, shared_username='someone')
response = self.tester.post(
'/browser/server/obj/{0}/'.format(utils.SERVER_GROUP),
data=json.dumps(server),
content_type='html/json'
)
self.assertEqual(response.status_code, 200, response.data)
self.server_id = json.loads(
response.data.decode('utf-8'))['node']['_id']
row = _server_row(self.app, self.server_id)
self.assertTrue(row['shared'])
self.assertEqual(row['shared_username'], 'someone')
def tearDown(self):
if self.server_id is not None:
utils.delete_server_with_api(
self.__class__.tester, self.server_id)
@@ -25,7 +25,7 @@ from pgadmin.utils.constants import MIMETYPE_APP_JS
from pgadmin.utils.ajax import make_json_response, internal_server_error, \
unauthorized
from pgadmin.model import ServerGroup, Server
from pgadmin.utils import clear_database_servers, dump_database_servers,\
from pgadmin.utils import dump_database_servers, \
load_database_servers, validate_json_data, \
filename_with_file_manager_path, get_file_manager_storage_dir
from urllib.parse import unquote
@@ -195,11 +195,11 @@ def save():
status, errmsg = \
dump_database_servers(data['filename'], data['selected_sever_ids'])
elif data['type'] == 'import':
# Clear all the existing servers
if 'replace_servers' in data and data['replace_servers']:
clear_database_servers()
status, errmsg = \
load_database_servers(data['filename'], data['selected_sever_ids'])
# The existing servers are cleared only once the file has been
# validated.
status, errmsg = load_database_servers(
data['filename'], data['selected_sever_ids'],
replace=bool(data.get('replace_servers')))
if not status:
return internal_server_error(errmsg)
Whitespace-only changes.
@@ -0,0 +1,128 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
"""A non-administrator cannot create a shared server through the
servers.json import route, as through the server API and dialog."""
import json
import os
import shutil
import tempfile
from unittest.mock import patch
import config
from pgadmin.model import db, Role, Server, ServerGroup, User
from pgadmin.utils.route import BaseTestGenerator
from regression.test_setup import config_data
from regression.python_test_utils.test_utils import \
create_user_wise_test_client
test_user_details = None
if config.SERVER_MODE:
test_user_details = \
config_data['pgAdmin4_test_non_admin_credentials']
GROUP = 'Import shared server non-admin test group'
HOST = 'evil.example'
def _grant_user_role(test_case, email):
"""Give the test user the stock 'User' role, which carries the import
and export permission; the test user is created without any role."""
app = test_case.app
with app.app_context():
user = User.query.filter_by(email=email).first()
role = Role.query.filter_by(name='User').first()
if role in user.roles:
return
user.roles.append(role)
db.session.commit()
def revoke():
with app.app_context():
user = User.query.filter_by(email=email).first()
role = Role.query.filter_by(name='User').first()
if role in user.roles:
user.roles.remove(role)
db.session.commit()
test_case.addCleanup(revoke)
def _evil_count(app):
with app.app_context():
return Server.query.filter_by(host=HOST).count()
class NonAdminImportSharedServerTestCase(BaseTestGenerator):
"""The import route refuses a mixed file, and imports nothing from a
valid file whose only selected server is shared."""
scenarios = [
('Shared server next to an invalid one is refused',
dict(invalid_entry=True, expected_status=500)),
('Selected shared server is skipped from a valid file',
dict(invalid_entry=False, expected_status=200)),
]
def setUp(self):
if not config.SERVER_MODE:
self.skipTest('Sharing only applies to server mode.')
self.tmp = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
self.addCleanup(self._cleanup)
def _cleanup(self):
with self.app.app_context():
Server.query.filter_by(host=HOST).delete()
for group in ServerGroup.query.filter_by(name=GROUP).all():
Server.query.filter_by(servergroup_id=group.id).delete()
db.session.delete(group)
db.session.commit()
@create_user_wise_test_client(test_user_details)
def runTest(self):
_grant_user_role(self, test_user_details['login_username'])
servers = {
'1': {'Group': GROUP, 'Name': 'evil', 'Shared': True,
'Host': HOST, 'Port': 5432, 'SharedUsername': 'postgres',
'MaintenanceDB': 'postgres'},
}
if self.invalid_entry:
servers['2'] = {'Group': GROUP, 'Name': 'no host',
'Port': 5432, 'Username': 'postgres',
'MaintenanceDB': 'postgres'}
else:
servers['2'] = {'Group': GROUP, 'Name': 'fine',
'Host': 'fine.example', 'Port': 5432,
'Username': 'postgres',
'MaintenanceDB': 'postgres'}
path = os.path.join(self.tmp, 'servers.json')
with open(path, 'w') as f:
json.dump({'Servers': servers}, f)
# The import opens the file beneath the storage directory, so make
# the scratch directory the storage directory.
with patch('pgadmin.utils.filename_with_file_manager_path',
return_value=path), \
patch('pgadmin.utils.get_file_manager_storage_dir',
return_value=self.tmp):
response = self.tester.post(
'/import_export_servers/save',
data=json.dumps({'type': 'import',
'filename': 'servers.json',
'selected_sever_ids': ['1']}),
content_type='html/json')
self.assertEqual(response.status_code, self.expected_status,
response.data)
self.assertEqual(_evil_count(self.app), 0)
with self.app.app_context():
self.assertEqual(
ServerGroup.query.filter_by(name=GROUP).count(), 0)
+80 -18
View File
@@ -983,22 +983,26 @@ def validate_json_data(data, is_admin):
:param is_admin:
:return: error message if any
"""
skip_servers = []
# Loop through the servers...
if "Servers" not in data:
return gettext("'Servers' attribute not found in the specified file.")
# Shared servers can only be imported by administrators. Prune them
# before validating, so that they are dropped even when the
# validation of the remaining servers fails.
if not is_admin:
skip_servers = [server for server, obj in data["Servers"].items()
if obj.get("Shared") is True]
for server in skip_servers:
print("Won't import the server '%s' as it is shared " %
data["Servers"][server].get("Name", server))
del data["Servers"][server]
for server in data["Servers"]:
obj = data["Servers"][server]
is_shared = obj.get("Shared", None)
# Check if server is shared.Won't import if user is non-admin
if is_shared and not is_admin:
print("Won't import the server '%s' as it is shared " %
obj["Name"])
skip_servers.append(server)
continue
# Only the JSON value true means shared, anything else is not.
is_shared = obj.get("Shared") is True
def check_attrib(attrib):
if attrib not in obj:
@@ -1053,11 +1057,47 @@ def validate_json_data(data, is_admin):
return gettext("'Host' or 'Service' attribute not "
"found for server '%s'" % server)
for server in skip_servers:
del data["Servers"][server]
return None
def check_non_admin_shared_servers(logger, cli_mode=False):
"""Server mode only: log a warning for each server that is shared by a
user who is not an administrator. Only administrators can share a
server now, but servers shared before that restriction stay shared
until they are un-shared. Read-only, and it never raises, so it cannot
block the startup. Credentials and host details are not logged.
Returns the number of servers found."""
if not config.SERVER_MODE or cli_mode:
return 0
try:
rows = db.session.query(Server.id, Server.name, Server.user_id) \
.filter(Server.shared.is_(True)).all()
found = 0
owner_is_admin = {}
for server_id, name, user_id in rows:
if user_id not in owner_is_admin:
owner = db.session.get(User, user_id)
owner_is_admin[user_id] = owner is not None and \
owner.has_role('Administrator')
if owner_is_admin[user_id]:
continue
found += 1
logger.warning(
"Server %s ('%s') is shared by user %s, who is not an "
"administrator. The owner can un-share it with a PUT of "
"\"shared\": false through the API, or an administrator "
"can change its 'shared' column in the configuration "
"database.", server_id, name, user_id)
return found
except Exception as e:
try:
logger.warning("Unable to check for servers shared by "
"non-administrators: %s", e)
except Exception:
pass
return 0
def _load_server_passexec(new_server, obj):
"""Apply PasswordExecCommand from an import file in server mode,
where it must be the name of a configured command. Anything else
@@ -1081,8 +1121,11 @@ def _load_server_passexec(new_server, obj):
def load_database_servers(input_file, selected_servers,
load_user=current_user, from_setup=False,
auth_source=INTERNAL):
auth_source=INTERNAL, replace=False):
"""Load server groups and servers.
:param replace: clear the user's existing servers once the file has
been validated, and just before the servers are loaded.
"""
user = _does_user_exist(load_user, from_setup, auth_source)
if user is None:
@@ -1149,15 +1192,32 @@ def load_database_servers(input_file, selected_servers,
groups = ServerGroup.query.filter_by(user_id=user_id)
# Validate server data
error_msg = validate_json_data(data, user.has_role("Administrator"))
if error_msg is not None and from_setup:
print(ADD_SERVERS_MSG % (groups_added, servers_added))
is_admin = user.has_role("Administrator")
error_msg = validate_json_data(data, is_admin)
if error_msg is not None:
if from_setup:
print(ADD_SERVERS_MSG % (groups_added, servers_added))
return _handle_error(error_msg, from_setup)
if replace:
# Clear the existing servers only once the file is known to be
# valid, so that a refused file does not lose the user's servers.
result = clear_database_servers(load_user, from_setup, auth_source)
if result is not None and result is not True:
return result
for server in data["Servers"]:
if selected_servers is None or str(server) in selected_servers:
obj = data["Servers"][server]
# Defence in depth: only administrators can import shared
# servers. Checked before the group is created, so that no
# empty group is left behind.
if obj.get("Shared") is True and not is_admin:
print("Won't import the server '%s' as it is shared " %
obj.get("Name", server))
continue
# Get the group. Create if necessary
group_id = next(
(g.id for g in groups if g.name == obj["Group"]), -1)
@@ -1181,7 +1241,8 @@ def load_database_servers(input_file, selected_servers,
groups_added = groups_added + 1
groups = ServerGroup.query.filter_by(user_id=user_id)
is_shared = obj.get("Shared", None)
# Only the JSON value true means shared, as in validation.
is_shared = obj.get("Shared") is True
username = obj.get("Username", None)
shared_username = obj.get("SharedUsername", None)
if is_shared and username is None:
@@ -1266,9 +1327,10 @@ def load_database_servers(input_file, selected_servers,
new_server.tunnel_keep_alive = \
obj.get("TunnelKeepAlive", None)
new_server.shared = is_shared
new_server.shared = is_shared and is_admin
new_server.shared_username = shared_username
new_server.shared_username = \
shared_username if new_server.shared else None
new_server.kerberos_conn = obj.get("KerberosAuthentication", None)
@@ -0,0 +1,96 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL License
#
##########################################################################
from unittest.mock import patch, MagicMock
from pgadmin.utils.route import BaseTestGenerator
from pgadmin.utils import check_non_admin_shared_servers
GROUP = 'Shared server audit test group'
class TestCheckNonAdminSharedServers(BaseTestGenerator):
"""Servers shared by non-administrators are reported at startup, in
server mode only, and never raise."""
scenarios = [
('Server mode', dict(server_mode=True, cli_mode=False)),
('Server mode, CLI', dict(server_mode=True, cli_mode=True)),
('Desktop mode', dict(server_mode=False, cli_mode=False)),
]
def setUp(self):
self.ctx = self.app.test_request_context()
self.ctx.push()
# Cleanups run last in, first out: the context pops last.
self.addCleanup(self.ctx.pop)
from pgadmin.model import User
self.user = User.query.first()
self.addCleanup(self._cleanup)
def _cleanup(self):
from pgadmin.model import db, Server, ServerGroup
db.session.rollback()
group = ServerGroup.query.filter_by(
user_id=self.user.id, name=GROUP).first()
if group:
Server.query.filter_by(servergroup_id=group.id).delete()
db.session.delete(group)
db.session.commit()
def _add(self, group, name, shared):
from pgadmin.model import db, Server
server = Server(user_id=self.user.id, servergroup_id=group.id,
name=name, host='db.example.com', port=5432,
username='test_user', maintenance_db='postgres',
shared=shared)
db.session.add(server)
db.session.commit()
return server
def runTest(self):
from pgadmin.model import db, ServerGroup
group = ServerGroup(user_id=self.user.id, name=GROUP)
db.session.add(group)
db.session.flush()
shared = self._add(group, 'shared one', True)
self._add(group, 'private one', False)
logger = MagicMock()
for is_admin in (False, True):
logger.reset_mock()
with patch('pgadmin.utils.config.SERVER_MODE',
self.server_mode), \
patch.object(type(self.user), 'has_role',
return_value=is_admin):
found = check_non_admin_shared_servers(
logger, self.cli_mode)
if self.server_mode and not self.cli_mode and not is_admin:
# Other shared servers of non-admins may exist in the
# test database, so look at ours.
self.assertGreaterEqual(found, 1)
texts = [c.args[0] % c.args[1:]
for c in logger.warning.call_args_list]
mine = [t for t in texts
if t.startswith('Server %s (' % shared.id)]
self.assertEqual(len(mine), 1)
self.assertIn("'shared one'", mine[0])
self.assertIn('user %s' % self.user.id, mine[0])
self.assertNotIn('private one', ' '.join(texts))
self.assertNotIn('db.example.com', ' '.join(texts))
else:
self.assertEqual(found, 0)
logger.warning.assert_not_called()
# Failures never propagate.
logger.reset_mock()
with patch('pgadmin.utils.config.SERVER_MODE', True), \
patch('pgadmin.utils.db.session.query',
side_effect=RuntimeError('boom')):
self.assertEqual(check_non_admin_shared_servers(logger), 0)
@@ -0,0 +1,295 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL License
#
##########################################################################
"""Only an administrator can create a shared server through the
servers.json import (load_database_servers)."""
import io
import json
import os
import shutil
import tempfile
from contextlib import redirect_stdout
from unittest.mock import patch
from pgadmin.utils.route import BaseTestGenerator
GROUP = 'Shared server import test group'
SHARED_GROUP = 'Shared server import test shared group'
GROUPS = (GROUP, SHARED_GROUP)
class _SharedImportMixin:
"""Shared set-up: a scratch directory, and clean test groups."""
is_admin = False
def setUp(self):
# gettext needs a request context to pick a locale.
self.ctx = self.app.test_request_context()
self.ctx.push()
from pgadmin.model import User
self.user = User.query.first()
self.tmp = tempfile.mkdtemp()
self.path = os.path.join(self.tmp, 'servers.json')
self._cleanup()
patcher = patch.object(type(self.user), 'has_role',
return_value=self.is_admin)
patcher.start()
self.addCleanup(patcher.stop)
def tearDown(self):
from pgadmin.model import db
db.session.rollback()
self._cleanup()
shutil.rmtree(self.tmp, ignore_errors=True)
self.ctx.pop()
def _cleanup(self):
from pgadmin.model import db, Server, ServerGroup
for name in GROUPS:
group = ServerGroup.query.filter_by(
user_id=self.user.id, name=name).first()
if group:
Server.query.filter_by(servergroup_id=group.id).delete()
db.session.delete(group)
db.session.commit()
def _entry(self, group=GROUP, **kwargs):
return dict({'Group': group, 'Host': 'db.example.com',
'Port': 5432, 'Username': 'test_user',
'MaintenanceDB': 'postgres'}, **kwargs)
def _write(self, servers):
with open(self.path, 'w') as f:
json.dump({'Servers': servers}, f)
def _load(self, selected, from_setup=False, **kwargs):
from pgadmin.utils import load_database_servers
out = io.StringIO()
# The web path opens the file beneath the storage directory, so
# make the scratch directory the storage directory.
with redirect_stdout(out), \
patch('pgadmin.utils.filename_with_file_manager_path',
return_value=self.path), \
patch('pgadmin.utils.get_file_manager_storage_dir',
return_value=self.tmp):
result = load_database_servers(
self.path, selected, self.user, from_setup=from_setup,
**kwargs)
return result, out.getvalue()
def _servers(self, group=None):
from pgadmin.model import Server, ServerGroup
groups = ServerGroup.query.filter(
ServerGroup.user_id == self.user.id,
ServerGroup.name.in_(GROUPS if group is None else [group]))
return Server.query.filter(
Server.servergroup_id.in_([g.id for g in groups])).all()
def _group_exists(self, name):
from pgadmin.model import ServerGroup
return ServerGroup.query.filter_by(
user_id=self.user.id, name=name).count() > 0
class TestNonAdminMixedFileRefused(_SharedImportMixin, BaseTestGenerator):
"""A file with a shared server and an invalid one is refused, and the
shared server is not created."""
def runTest(self):
self._write({
'1': self._entry(group=SHARED_GROUP, Name='evil', Shared=True,
SharedUsername='postgres'),
'2': {'Group': GROUP, 'Name': 'no host', 'Port': 5432,
'Username': 'test_user', 'MaintenanceDB': 'postgres'},
})
(ok, msg), _ = self._load(['1'])
self.assertFalse(ok)
self.assertIn("'Host' or 'Service' attribute not found", msg)
self.assertEqual(self._servers(), [])
self.assertFalse(self._group_exists(SHARED_GROUP))
class TestNonAdminSharedEntrySkipped(_SharedImportMixin, BaseTestGenerator):
"""Only the non-shared server is imported, and no empty group is left
for the shared one."""
def runTest(self):
self._write({
'1': self._entry(group=SHARED_GROUP, Name='shared one',
Shared=True, SharedUsername='postgres'),
'2': self._entry(Name='private one'),
})
(ok, _), out = self._load(['1', '2'])
self.assertTrue(ok)
self.assertIn("Won't import the server 'shared one'", out)
servers = self._servers()
self.assertEqual([s.name for s in servers], ['private one'])
self.assertFalse(servers[0].shared)
self.assertFalse(self._group_exists(SHARED_GROUP))
class TestNonAdminSharedEntryNotValidated(
_SharedImportMixin, BaseTestGenerator):
"""The loader refuses a shared entry itself, even if validation let
it through."""
def runTest(self):
self._write({
'1': self._entry(group=SHARED_GROUP, Name='shared one',
Shared=True, SharedUsername='postgres'),
})
with patch('pgadmin.utils.validate_json_data', return_value=None):
(ok, _), out = self._load(['1'])
self.assertTrue(ok)
self.assertIn("Won't import the server 'shared one'", out)
self.assertEqual(self._servers(), [])
self.assertFalse(self._group_exists(SHARED_GROUP))
class TestAdminImportsSharedServer(_SharedImportMixin, BaseTestGenerator):
"""An administrator can import a shared server."""
is_admin = True
def runTest(self):
self._write({
'1': self._entry(Name='shared one', Shared=True,
SharedUsername='postgres', Username=None),
'2': self._entry(Name='private one', SharedUsername='ignored'),
})
(ok, _), _ = self._load(['1', '2'])
self.assertTrue(ok)
servers = {s.name: s for s in self._servers()}
self.assertTrue(servers['shared one'].shared)
self.assertEqual(servers['shared one'].shared_username, 'postgres')
self.assertEqual(servers['shared one'].username, 'postgres')
self.assertFalse(servers['private one'].shared)
self.assertIsNone(servers['private one'].shared_username)
class TestSetupNonAdmin(_SharedImportMixin, BaseTestGenerator):
"""The setup.py path skips shared servers for a non-admin and exits on
an invalid file."""
def runTest(self):
self._write({
'1': self._entry(group=SHARED_GROUP, Name='shared one',
Shared=True, SharedUsername='postgres'),
'2': self._entry(Name='private one'),
})
(ok, _), out = self._load(None, from_setup=True)
self.assertTrue(ok)
self.assertIn("Won't import the server 'shared one'", out)
self.assertEqual([s.name for s in self._servers()], ['private one'])
self._write({
'1': self._entry(group=SHARED_GROUP, Name='shared one',
Shared=True, SharedUsername='postgres'),
'2': {'Group': GROUP, 'Name': 'no host', 'Port': 5432,
'Username': 'test_user', 'MaintenanceDB': 'postgres'},
})
with self.assertRaises(SystemExit):
self._load(None, from_setup=True)
self.assertFalse(self._group_exists(SHARED_GROUP))
class TestReplaceKeepsServersOnInvalidFile(
_SharedImportMixin, BaseTestGenerator):
"""replace=True clears the existing servers only after the file has
been validated."""
def runTest(self):
from pgadmin.model import db, Server, ServerGroup
group = ServerGroup(user_id=self.user.id, name=GROUP)
db.session.add(group)
db.session.flush()
db.session.add(Server(
user_id=self.user.id, servergroup_id=group.id, name='existing',
host='db.example.com', port=5432, username='test_user',
maintenance_db='postgres'))
db.session.commit()
self._write({
'1': {'Group': GROUP, 'Name': 'no host', 'Port': 5432,
'Username': 'test_user', 'MaintenanceDB': 'postgres'},
})
with patch('pgadmin.utils.clear_database_servers') as mock_clear:
(ok, _), _ = self._load(['1'], replace=True)
self.assertFalse(ok)
mock_clear.assert_not_called()
self.assertEqual([s.name for s in self._servers()], ['existing'])
class TestAdminSharedMustBeJsonTrue(_SharedImportMixin, BaseTestGenerator):
"""Only the JSON value true means shared. A string such as "false" or
a number such as 1 is truthy in Python but must not share a server."""
is_admin = True
def runTest(self):
self._write({
'1': self._entry(Name='string false', Shared='false'),
'2': self._entry(Name='number one', Shared=1),
'3': self._entry(Name='real true', Shared=True),
})
(ok, _), _ = self._load(['1', '2', '3'])
self.assertTrue(ok)
servers = {s.name: s for s in self._servers()}
self.assertFalse(servers['string false'].shared)
self.assertFalse(servers['number one'].shared)
self.assertTrue(servers['real true'].shared)
class TestNonAdminSharedStringFalseIsPrivate(
_SharedImportMixin, BaseTestGenerator):
""""Shared": "false" is not shared, so a non-admin imports it as a
normal private server, while a real true is still skipped."""
def runTest(self):
self._write({
'1': self._entry(Name='string false', Shared='false'),
'2': self._entry(group=SHARED_GROUP, Name='real true',
Shared=True, SharedUsername='postgres'),
})
(ok, _), out = self._load(['1', '2'])
self.assertTrue(ok)
self.assertIn("Won't import the server 'real true'", out)
servers = self._servers()
self.assertEqual([s.name for s in servers], ['string false'])
self.assertFalse(servers[0].shared)
self.assertFalse(self._group_exists(SHARED_GROUP))
class TestReplaceWithValidFile(_SharedImportMixin, BaseTestGenerator):
"""replace=True with a valid file calls clear_database_servers, and
still imports the new servers.
clear_database_servers is mocked: the real one deletes every server of
the user, including the server the rest of the regression suite
connects to.
"""
def runTest(self):
self._write({'1': self._entry(Name='imported')})
with patch('pgadmin.utils.clear_database_servers',
return_value=None) as mock_clear:
(ok, _), _ = self._load(['1'], replace=True)
mock_clear.assert_called_once()
self.assertEqual(mock_clear.call_args[0][0].id, self.user.id)
self.assertTrue(ok)
self.assertEqual([s.name for s in self._servers()], ['imported'])
@@ -131,6 +131,39 @@ class TestValidateJsonData(BaseTestGenerator):
expected_error=None,
expected_servers=["2"]
)),
('A non-admin shared server is dropped even when another server '
'fails validation',
dict(
servers={"1": server(Shared=True, SharedUsername="postgres"),
"2": {"Group": "Servers", "Name": "No host",
"Port": 5432, "Username": "postgres",
"MaintenanceDB": "postgres"}},
is_admin=False,
expected_error="'Host' or 'Service' attribute not found",
expected_servers=["2"]
)),
('A non-admin shared server without a name is dropped quietly',
dict(
servers={"1": {"Group": "Servers", "Shared": True,
"Host": "127.0.0.1", "Port": 5432,
"SharedUsername": "postgres",
"MaintenanceDB": "postgres"},
"2": server(Username="postgres")},
is_admin=False,
expected_error=None,
expected_servers=["2"]
)),
('An admin keeps the shared server when another one fails '
'validation',
dict(
servers={"1": server(Shared=True, SharedUsername="postgres"),
"2": {"Group": "Servers", "Name": "No host",
"Port": 5432, "Username": "postgres",
"MaintenanceDB": "postgres"}},
is_admin=True,
expected_error="'Host' or 'Service' attribute not found",
expected_servers=["1", "2"]
)),
]
def runTest(self):
@@ -11,7 +11,7 @@
import _ from 'lodash';
import pgAdmin from 'sources/pgadmin';
import current_user from 'pgadmin.user_management.current_user';
import ServerSchema, { passexecForServerCopy, starfleetConnectionParams } from '../../../pgadmin/browser/server_groups/servers/static/js/server.ui';
import ServerSchema, { passexecForServerCopy, sharingForServerCopy, starfleetConnectionParams } from '../../../pgadmin/browser/server_groups/servers/static/js/server.ui';
import {genericBeforeEach, getCreateView, getEditView, getPropertiesView} from '../genericFunctions';
describe('ServerSchema', ()=>{
@@ -237,4 +237,31 @@ describe('ServerSchema', ()=>{
.toBeUndefined();
});
});
describe('sharingForServerCopy', ()=>{
it('clears the sharing of a copy made by a non-admin', ()=>{
let d = {name: 'x', shared: true, shared_username: 'someone'};
expect(sharingForServerCopy(d, {is_admin: false})).toBe(d);
expect(d.shared).toBe(false);
expect(d.shared_username).toBeNull();
d = {shared: true, shared_username: 'someone'};
sharingForServerCopy(d, undefined);
expect(d.shared).toBe(false);
expect(d.shared_username).toBeNull();
});
it('keeps the sharing of a copy made by an admin', ()=>{
let d = {name: 'x', shared: true, shared_username: 'someone'};
expect(sharingForServerCopy(d, {is_admin: true})).toBe(d);
expect(d.shared).toBe(true);
expect(d.shared_username).toBe('someone');
});
it('leaves a server that is not shared untouched', ()=>{
let d = {name: 'x', shared: false, shared_username: 'someone'};
sharingForServerCopy(d, {is_admin: false});
expect(d).toEqual({name: 'x', shared: false, shared_username: 'someone'});
});
});
});
+2 -4
View File
@@ -40,7 +40,7 @@ else:
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, \
from pgadmin.utils import dump_database_servers, \
load_database_servers, _handle_error
from pgadmin.utils.session import cleanup_session_files
from pgadmin.setup import db_upgrade, create_app_data_directory
@@ -124,10 +124,8 @@ class ManageServers:
try:
app = create_app(config.APP_NAME + '-cli')
with app.test_request_context():
if replace:
clear_database_servers(load_user, True, auth_source)
load_database_servers(input_file, None, load_user, True,
auth_source)
auth_source, replace=replace)
except Exception as e:
print(str(e))