Skip to content

Commit e4edcf2

Browse files
authored
fix: SharedServer feature parity columns and write guards (#9835)
Add passexec_cmd, passexec_expiration, kerberos_conn, tags, and post_connection_sql to SharedServer so non-owners get their own per-user values instead of inheriting the owner's. Drop the unused db_res column which was never overlaid or writable by non-owners. Key changes: - New Alembic migration (sharedserver_feature_parity) adds 5 columns, drops db_res, cleans up orphaned records. All operations idempotent. - Overlay copies new fields from SharedServer instead of suppressing - _owner_only_fields guard blocks non-owners from setting passexec_cmd, passexec_expiration, db_res, db_res_type via API - Non-owners can set post_connection_sql (runs under their own creds) - update_tags and flag_modified use sharedserver for non-owners - update() response returns sharedserver tags for non-owners - ServerManager passexec suppression with config.SERVER_MODE guard - UI: post_connection_sql editable for non-owners (readonly only when connected, not when shared) - SCHEMA_VERSION bumped to 51 - Comprehensive unit tests for overlay, write guards, and tag deltas
1 parent 4ddb16f commit e4edcf2

File tree

6 files changed

+361
-42
lines changed

6 files changed

+361
-42
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
##########################################################################
2+
#
3+
# pgAdmin 4 - PostgreSQL Tools
4+
#
5+
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
6+
# This software is released under the PostgreSQL Licence
7+
#
8+
##########################################################################
9+
10+
"""SharedServer feature parity columns, orphan cleanup, and db_res removal
11+
12+
Adds passexec_cmd, passexec_expiration, kerberos_conn, tags, and
13+
post_connection_sql to the sharedserver table so non-owners get their
14+
own per-user values instead of inheriting the owner's. Drops db_res
15+
which was never overlaid or writable by non-owners. Also cleans up
16+
orphaned records whose parent server was deleted.
17+
18+
Revision ID: sharedserver_feature_parity
19+
Revises: add_user_id_dbg_args
20+
Create Date: 2026-04-13
21+
22+
"""
23+
from alembic import op
24+
import sqlalchemy as sa
25+
26+
# revision identifiers, used by Alembic.
27+
revision = 'sharedserver_feature_parity'
28+
down_revision = 'add_user_id_dbg_args'
29+
branch_labels = None
30+
depends_on = None
31+
32+
33+
def upgrade():
34+
conn = op.get_bind()
35+
inspector = sa.inspect(conn)
36+
37+
if not inspector.has_table('sharedserver'):
38+
return
39+
40+
# Clean up orphaned SharedServer records whose osid
41+
# references a Server that no longer exists.
42+
op.execute(
43+
"DELETE FROM sharedserver WHERE osid NOT IN "
44+
"(SELECT id FROM server)"
45+
)
46+
47+
# Add missing columns (idempotent — guard against re-runs).
48+
existing_cols = {
49+
c['name'] for c in inspector.get_columns('sharedserver')
50+
}
51+
new_columns = [
52+
('passexec_cmd',
53+
sa.Column('passexec_cmd', sa.Text(),
54+
nullable=True)),
55+
('passexec_expiration',
56+
sa.Column('passexec_expiration', sa.Integer(),
57+
nullable=True)),
58+
('kerberos_conn',
59+
sa.Column('kerberos_conn', sa.Boolean(),
60+
nullable=False,
61+
server_default=sa.false())),
62+
('tags',
63+
sa.Column('tags', sa.JSON(), nullable=True)),
64+
('post_connection_sql',
65+
sa.Column('post_connection_sql', sa.String(),
66+
nullable=True)),
67+
]
68+
cols_to_add = [
69+
col for name, col in new_columns
70+
if name not in existing_cols
71+
]
72+
if cols_to_add:
73+
with op.batch_alter_table('sharedserver') as batch_op:
74+
for col in cols_to_add:
75+
batch_op.add_column(col)
76+
77+
# Drop db_res — database restrictions are an owner-level
78+
# concept; the column was never overlaid or writable by
79+
# non-owners and its presence invites accidental leakage.
80+
if 'db_res' in existing_cols:
81+
with op.batch_alter_table('sharedserver') as batch_op:
82+
batch_op.drop_column('db_res')
83+
84+
85+
def downgrade():
86+
# pgAdmin only upgrades, downgrade not implemented.
87+
pass

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

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -172,16 +172,17 @@ def get_shared_server_properties(server, sharedserver):
172172
Return shared server properties.
173173
174174
Overlays per-user SharedServer values onto the owner's Server
175-
object. Security-sensitive fields that are absent from the
176-
SharedServer model (passexec_cmd, post_connection_sql) are
177-
suppressed for non-owners.
175+
object so each non-owner sees their own customizations.
178176
179177
The server is expunged from the SQLAlchemy session before
180178
mutation so that the owner's record is never dirtied.
181179
:param server:
182180
:param sharedserver:
183181
:return: shared server (detached)
184182
"""
183+
if sharedserver is None:
184+
return server
185+
185186
# Detach from session so in-place mutations are never
186187
# flushed back to the owner's Server row.
187188
sess = object_session(server)
@@ -224,13 +225,11 @@ def get_shared_server_properties(server, sharedserver):
224225
server.server_owner = sharedserver.server_owner
225226
server.password = sharedserver.password
226227
server.prepare_threshold = sharedserver.prepare_threshold
227-
228-
# Suppress owner-only fields that are absent from SharedServer
229-
# and dangerous when inherited (privilege escalation / code
230-
# execution).
231-
server.passexec_cmd = None
232-
server.passexec_expiration = None
233-
server.post_connection_sql = None
228+
server.passexec_cmd = sharedserver.passexec_cmd
229+
server.passexec_expiration = sharedserver.passexec_expiration
230+
server.kerberos_conn = sharedserver.kerberos_conn
231+
server.tags = sharedserver.tags
232+
server.post_connection_sql = sharedserver.post_connection_sql
234233

235234
return server
236235

@@ -477,7 +476,12 @@ def create_shared_server(data, gid):
477476
tunnel_prompt_password=0,
478477
shared=True,
479478
connection_params=safe_conn_params,
480-
prepare_threshold=data.prepare_threshold
479+
prepare_threshold=data.prepare_threshold,
480+
passexec_cmd=None,
481+
passexec_expiration=None,
482+
kerberos_conn=False,
483+
tags=None,
484+
post_connection_sql=None
481485
)
482486
db.session.add(shared_server)
483487
db.session.commit()
@@ -904,7 +908,7 @@ def update(self, gid, sid):
904908

905909
# Update connection parameter if any.
906910
self.update_connection_parameter(data, server, sharedserver)
907-
self.update_tags(data, server)
911+
self.update_tags(data, sharedserver or server)
908912

909913
if 'connection_params' in data and \
910914
'hostaddr' in data['connection_params'] and \
@@ -937,7 +941,7 @@ def update(self, gid, sid):
937941

938942
# tags is JSON type, sqlalchemy sometimes will not detect change
939943
if 'tags' in data:
940-
flag_modified(server, 'tags')
944+
flag_modified(sharedserver or server, 'tags')
941945

942946
try:
943947
db.session.commit()
@@ -953,6 +957,10 @@ def update(self, gid, sid):
953957
# which will affect the connections.
954958
if not conn.connected():
955959
manager.update(server)
960+
# Suppress passexec for non-owners so the manager
961+
# never holds the owner's password-exec command.
962+
if _is_non_owner(server):
963+
manager.passexec = None
956964

957965
return jsonify(
958966
node=self.blueprint.generate_browser_node(
@@ -974,7 +982,7 @@ def update(self, gid, sid):
974982
role=server.role,
975983
is_password_saved=bool(server.save_password),
976984
description=server.comment,
977-
tags=server.tags
985+
tags=(sharedserver or server).tags
978986
)
979987
)
980988

@@ -998,8 +1006,20 @@ def _set_valid_attr_value(self, gid, data, config_param_map, server,
9981006
if not crypt_key_present:
9991007
raise CryptKeyMissing
10001008

1009+
# Fields that non-owners must never set on their
1010+
# SharedServer — they enable command/SQL execution
1011+
# or are owner-level concepts not on SharedServer.
1012+
_owner_only_fields = frozenset({
1013+
'passexec_cmd', 'passexec_expiration',
1014+
'db_res', 'db_res_type',
1015+
})
1016+
10011017
for arg in config_param_map:
10021018
if arg in data:
1019+
# Non-owners cannot set dangerous fields.
1020+
if _is_non_owner(server) and \
1021+
arg in _owner_only_fields:
1022+
continue
10031023
value = data[arg]
10041024
if arg == 'password':
10051025
value = encrypt(data[arg], crypt_key)
@@ -1159,14 +1179,8 @@ def properties(self, gid, sid):
11591179
'fgcolor': server.fgcolor,
11601180
'db_res': get_db_restriction(server.db_res_type, server.db_res),
11611181
'db_res_type': server.db_res_type,
1162-
'passexec_cmd':
1163-
server.passexec_cmd
1164-
if server.passexec_cmd and
1165-
not _is_non_owner(server) else None,
1166-
'passexec_expiration':
1167-
server.passexec_expiration
1168-
if server.passexec_expiration and
1169-
not _is_non_owner(server) else None,
1182+
'passexec_cmd': server.passexec_cmd,
1183+
'passexec_expiration': server.passexec_expiration,
11701184
'service': server.service if server.service else None,
11711185
'use_ssh_tunnel': use_ssh_tunnel,
11721186
'tunnel_host': tunnel_host,
@@ -1186,8 +1200,7 @@ def properties(self, gid, sid):
11861200
'connection_string': display_connection_str,
11871201
'prepare_threshold': server.prepare_threshold,
11881202
'tags': tags,
1189-
'post_connection_sql': server.post_connection_sql
1190-
if not _is_non_owner(server) else None,
1203+
'post_connection_sql': server.post_connection_sql,
11911204
}
11921205

11931206
return ajax_response(response)
@@ -1605,6 +1618,12 @@ def connect(self, gid, sid, is_qt=False, server=None):
16051618
# the API call is not made from SQL Editor or View/Edit Data tool
16061619
if not manager.connection().connected() and not is_qt:
16071620
manager.update(server)
1621+
# Re-suppress passexec after update() which rebuilds
1622+
# from the (overlaid) server object. Belt-and-suspenders:
1623+
# the overlay already defaults passexec to None, but this
1624+
# guards against direct DB edits.
1625+
if _is_non_owner(server):
1626+
manager.passexec = None
16081627
conn = manager.connection()
16091628

16101629
# Get enc key

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -587,7 +587,7 @@ export default class ServerSchema extends BaseUISchema {
587587
group: gettext('Post Connection SQL'),
588588
mode: ['properties', 'edit', 'create'],
589589
type: 'sql', isFullTab: true,
590-
readonly: obj.isConnectedOrShared,
590+
readonly: obj.isConnected,
591591
helpMessage: gettext('Any query specified in the control below will be executed with autocommit mode enabled for each connection to any database on this server.'),
592592
},
593593
{

0 commit comments

Comments
 (0)