Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 56 additions & 8 deletions web/migrations/versions/add_user_id_to_debugger_func_args_.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,18 @@ def upgrade():
if inspector.has_table(table_name):
op.execute(stmt)

# --- Unique constraint on SharedServer(osid, user_id) ---
# Prevents duplicate SharedServer records from TOCTOU race.
# First remove duplicates (keep lowest id per osid+user_id).
# --- SharedServer cleanup and constraints ---
if inspector.has_table('sharedserver'):
# Clean up orphaned SharedServer records whose osid
# references a Server that no longer exists.
conn.execute(sa.text(
"DELETE FROM sharedserver WHERE osid NOT IN "
"(SELECT id FROM server)"
))

# Deduplicate SharedServer records that would violate
# the unique constraint. Keep the record with the
# lowest id (oldest).
if dialect == 'sqlite':
op.execute(
'DELETE FROM sharedserver WHERE id NOT IN '
Expand All @@ -137,11 +145,51 @@ def upgrade():
'AND s1.user_id = s2.user_id '
'AND s1.id > s2.id'
)
with op.batch_alter_table('sharedserver') as batch:
batch.create_unique_constraint(
'uq_sharedserver_osid_user',
['osid', 'user_id']
)

# Add missing columns to sharedserver (guard against
# re-runs where columns may already exist).
existing_cols = {
c['name'] for c in inspector.get_columns('sharedserver')
}
new_columns = [
('passexec_cmd',
sa.Column('passexec_cmd', sa.Text(),
nullable=True)),
('passexec_expiration',
sa.Column('passexec_expiration', sa.Integer(),
nullable=True)),
('kerberos_conn',
sa.Column('kerberos_conn', sa.Boolean(),
nullable=False,
server_default='false')),
Comment on lines +161 to +164
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In SQLAlchemy/Alembic, for a Boolean column, does server_default='false'render as a quoted string literal or a SQL boolean literal, and isserver_default=sa.text('false')orsa.false() the recommended portable form?

💡 Result:

  • server_default='false' is treated as a SQL expression string, not a Python boolean. In DDL it will typically render as DEFAULT false (unquoted), i.e. not DEFAULT 'false'—SQLAlchemy only applies string-literal quoting rules when the default string itself represents a quoted literal value. [1]
  • server_default=sa.text('false') likewise renders DEFAULT false; it’s just the explicit “this is SQL text” form (SQLAlchemy docs advise text() over raw strings for literal SQL defaults). [2]
  • For the most portable/cross-dialect boolean server default, prefer server_default=sa.false() (or sa.true()), since SQLAlchemy can adapt true/false constants on backends that don’t support native boolean literals. [3] (This is also the common Alembic/SQLAlchemy migration pattern you’ll see recommended in practice. [4])

Sources: [1] SQLAlchemy migration note on string server_default quoting behavior (docs.sqlalchemy.org); [2] SQLAlchemy defaults docs recommending text() for literal SQL in server_default (docs.sqlalchemy.org); [3] SQLAlchemy false() constant portability across backends (docs.sqlalchemy.org); [4] Alembic/SQLAlchemy usage example recommending sa.false() (stackoverflow.com).

Citations:


Use sa.false() for the kerberos_conn server default.

server_default='false' works, but sa.false() is the recommended portable form in Alembic/SQLAlchemy. It ensures the default renders as a proper SQL boolean literal across different database backends (including SQLite) rather than relying on string-to-boolean coercion.

🔧 Suggested fix
             ('kerberos_conn',
              sa.Column('kerberos_conn', sa.Boolean(),
                        nullable=False,
-                       server_default='false')),
+                       server_default=sa.false())),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
('kerberos_conn',
sa.Column('kerberos_conn', sa.Boolean(),
nullable=False,
server_default='false')),
('kerberos_conn',
sa.Column('kerberos_conn', sa.Boolean(),
nullable=False,
server_default=sa.false())),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/migrations/versions/add_user_id_to_debugger_func_args_.py` around lines
161 - 164, The migration defines the 'kerberos_conn' column with
server_default='false'; change this to use SQLAlchemy's portable boolean literal
by setting server_default=sa.false() on the sa.Column(...) for 'kerberos_conn'
(and ensure sa.false is available from the imported sa namespace if not
already). This ensures the default renders as a proper SQL boolean literal
across DB backends.

('tags',
sa.Column('tags', sa.JSON(), nullable=True)),
('post_connection_sql',
sa.Column('post_connection_sql', sa.String(),
nullable=True)),
]
cols_to_add = [
col for name, col in new_columns
if name not in existing_cols
]
if cols_to_add:
with op.batch_alter_table('sharedserver') as batch_op:
for col in cols_to_add:
batch_op.add_column(col)

# Unique constraint prevents duplicate SharedServer
# records from TOCTOU race conditions.
existing_ucs = {
uc['name']
for uc in inspector.get_unique_constraints(
'sharedserver')
}
if 'uq_sharedserver_osid_user' not in existing_ucs:
with op.batch_alter_table('sharedserver') as batch:
batch.create_unique_constraint(
'uq_sharedserver_osid_user',
['osid', 'user_id']
)


def downgrade():
Expand Down
55 changes: 38 additions & 17 deletions web/pgadmin/browser/server_groups/servers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,16 +172,17 @@ def get_shared_server_properties(server, sharedserver):
Return shared server properties.

Overlays per-user SharedServer values onto the owner's Server
object. Security-sensitive fields that are absent from the
SharedServer model (passexec_cmd, post_connection_sql) are
suppressed for non-owners.
object so each non-owner sees their own customizations.

The server is expunged from the SQLAlchemy session before
mutation so that the owner's record is never dirtied.
:param server:
:param sharedserver:
:return: shared server (detached)
"""
if sharedserver is None:
return server

# Detach from session so in-place mutations are never
# flushed back to the owner's Server row.
sess = object_session(server)
Expand Down Expand Up @@ -224,13 +225,11 @@ def get_shared_server_properties(server, sharedserver):
server.server_owner = sharedserver.server_owner
server.password = sharedserver.password
server.prepare_threshold = sharedserver.prepare_threshold

# Suppress owner-only fields that are absent from SharedServer
# and dangerous when inherited (privilege escalation / code
# execution).
server.passexec_cmd = None
server.passexec_expiration = None
server.post_connection_sql = None
server.passexec_cmd = sharedserver.passexec_cmd
server.passexec_expiration = sharedserver.passexec_expiration
server.kerberos_conn = sharedserver.kerberos_conn
server.tags = sharedserver.tags
Comment on lines +230 to +231
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use sharedserver.tags as the merge base for non-owner updates.

Now that tags is overlaid from SharedServer, update() still builds tag deltas from server.tags on the owner row. Editing an existing shared copy can therefore rebuild the list from the owner's tags and overwrite the non-owner's current SharedServer.tags.

🔧 Suggested fix
-        self.update_tags(data, server)
+        self.update_tags(data, sharedserver or server)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/browser/server_groups/servers/__init__.py` around lines 230 -
231, The update path currently computes tag deltas using server.tags (the
owner's tags) which causes non-owner SharedServer.tags to be rebuilt from the
owner; change the merge base so that when handling a non-owner update (where
SharedServer exists and you assign server.kerberos_conn =
sharedserver.kerberos_conn and server.tags = sharedserver.tags) you compute tag
diffs against sharedserver.tags (i.e. use SharedServer.tags as the base) instead
of server.tags inside update(), and apply only the delta to the non-owner
SharedServer record to avoid overwriting its existing tags.

server.post_connection_sql = sharedserver.post_connection_sql

return server

Expand Down Expand Up @@ -477,7 +476,12 @@ def create_shared_server(data, gid):
tunnel_prompt_password=0,
shared=True,
connection_params=safe_conn_params,
prepare_threshold=data.prepare_threshold
prepare_threshold=data.prepare_threshold,
passexec_cmd=None,
passexec_expiration=None,
kerberos_conn=False,
tags=None,
post_connection_sql=None
)
db.session.add(shared_server)
db.session.commit()
Expand Down Expand Up @@ -998,8 +1002,21 @@ def _set_valid_attr_value(self, gid, data, config_param_map, server,
if not crypt_key_present:
raise CryptKeyMissing

# Fields that non-owners must never set on their
# SharedServer — they enable command/SQL execution
# or are owner-level concepts not on SharedServer.
_owner_only_fields = frozenset({
'passexec_cmd', 'passexec_expiration',
'post_connection_sql',
'db_res', 'db_res_type',
})

for arg in config_param_map:
if arg in data:
# Non-owners cannot set dangerous fields.
if _is_non_owner(server) and \
arg in _owner_only_fields:
continue
value = data[arg]
if arg == 'password':
value = encrypt(data[arg], crypt_key)
Expand Down Expand Up @@ -1161,12 +1178,10 @@ def properties(self, gid, sid):
'db_res_type': server.db_res_type,
'passexec_cmd':
server.passexec_cmd
if server.passexec_cmd and
not _is_non_owner(server) else None,
if server.passexec_cmd else None,
'passexec_expiration':
server.passexec_expiration
if server.passexec_expiration and
not _is_non_owner(server) else None,
if server.passexec_expiration else None,
Comment on lines 1182 to +1184
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep 0 distinct from None for passexec_expiration.

This truthiness check collapses an explicit 0 to None, so that value will not round-trip through the properties API.

🔧 Suggested fix
-            'passexec_expiration':
-                server.passexec_expiration
-                if server.passexec_expiration else None,
+            'passexec_expiration': server.passexec_expiration,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'passexec_expiration':
server.passexec_expiration
if server.passexec_expiration and
not _is_non_owner(server) else None,
if server.passexec_expiration else None,
'passexec_expiration': server.passexec_expiration,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/browser/server_groups/servers/__init__.py` around lines 1182 -
1184, The current truthiness check for passexec_expiration collapses an explicit
0 to None; change the conditional to test explicitly for None (e.g. use "if
server.passexec_expiration is not None else None") so that a value of 0 is
preserved when building the properties dict for passexec_expiration and will
round-trip correctly through the properties API.

'service': server.service if server.service else None,
'use_ssh_tunnel': use_ssh_tunnel,
'tunnel_host': tunnel_host,
Expand All @@ -1186,8 +1201,7 @@ def properties(self, gid, sid):
'connection_string': display_connection_str,
'prepare_threshold': server.prepare_threshold,
'tags': tags,
'post_connection_sql': server.post_connection_sql
if not _is_non_owner(server) else None,
'post_connection_sql': server.post_connection_sql,
}

return ajax_response(response)
Expand Down Expand Up @@ -1605,6 +1619,13 @@ def connect(self, gid, sid, is_qt=False, server=None):
# the API call is not made from SQL Editor or View/Edit Data tool
if not manager.connection().connected() and not is_qt:
manager.update(server)
# Re-suppress owner-only fields after update() which
# rebuilds them from the (overlaid) server object.
# Belt-and-suspenders: the overlay already defaults
# these to None, but this guards against direct DB edits.
if _is_non_owner(server):
manager.passexec = None
manager.post_connection_sql = None
conn = manager.connection()

# Get enc key
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ def _make_shared_server(**overrides):
'sslcert': '/home/nonowner/.ssl/cert.pem',
'connect_timeout': '10',
},
passexec_cmd=None,
passexec_expiration=None,
kerberos_conn=False,
tags=None,
post_connection_sql=None,
)
defaults.update(overrides)
ss = MagicMock()
Expand All @@ -97,10 +102,12 @@ class TestGetSharedServerProperties(BaseTestGenerator):
using mock objects."""

scenarios = [
('Merge suppresses passexec_cmd',
dict(test_method='test_suppresses_passexec')),
('Merge suppresses post_connection_sql',
dict(test_method='test_suppresses_post_sql')),
('Merge overlays passexec_cmd from SharedServer',
dict(test_method='test_overlays_passexec')),
('Merge overlays post_connection_sql from SharedServer',
dict(test_method='test_overlays_post_sql')),
('Merge overlays kerberos_conn and tags',
dict(test_method='test_overlays_kerberos_tags')),
('Merge strips owner SSL paths not in SharedServer',
dict(test_method='test_strips_owner_ssl_paths')),
('Merge applies SharedServer SSL paths',
Expand All @@ -111,6 +118,8 @@ class TestGetSharedServerProperties(BaseTestGenerator):
dict(test_method='test_overrides_tunnel')),
('Merge handles None connection_params',
dict(test_method='test_none_conn_params')),
('Merge returns server unchanged when sharedserver is None',
dict(test_method='test_null_guard')),
]

@patch('pgadmin.browser.server_groups.servers.'
Expand All @@ -128,14 +137,41 @@ def _merge(self, server=None, ss=None):
return ServerModule.get_shared_server_properties(
server, ss)

def test_suppresses_passexec(self):
def test_overlays_passexec(self):
# SharedServer defaults have None - overlay copies that.
result = self._merge()
self.assertIsNone(result.passexec_cmd)
self.assertIsNone(result.passexec_expiration)

def test_suppresses_post_sql(self):
# If SharedServer has a value, it should appear.
ss = _make_shared_server(
passexec_cmd='/usr/bin/get-pw',
passexec_expiration=120)
result = self._merge(ss=ss)
self.assertEqual(result.passexec_cmd, '/usr/bin/get-pw')
self.assertEqual(result.passexec_expiration, 120)

def test_overlays_post_sql(self):
# SharedServer defaults have None - overlay copies that.
result = self._merge()
self.assertIsNone(result.post_connection_sql)
# If SharedServer has a value, it should appear.
ss = _make_shared_server(
post_connection_sql='SET role reader;')
result = self._merge(ss=ss)
self.assertEqual(
result.post_connection_sql, 'SET role reader;')

def test_overlays_kerberos_tags(self):
result = self._merge()
self.assertFalse(result.kerberos_conn)
self.assertIsNone(result.tags)
# With values set on SharedServer
ss = _make_shared_server(
kerberos_conn=True,
tags=[{'text': 'prod', 'color': '#f00'}])
result = self._merge(ss=ss)
self.assertTrue(result.kerberos_conn)
self.assertEqual(len(result.tags), 1)

def test_strips_owner_ssl_paths(self):
result = self._merge()
Expand Down Expand Up @@ -180,6 +216,18 @@ def test_none_conn_params(self):
# Should not crash; connection_params becomes {}
self.assertEqual(result.connection_params, {})

def test_null_guard(self):
from pgadmin.browser.server_groups.servers import \
ServerModule
server = _make_server()
# Call directly to bypass _merge's None replacement
result = ServerModule.get_shared_server_properties(
server, None)
# Should return server unchanged
self.assertEqual(result.name, 'OwnerServer')
self.assertEqual(result.passexec_cmd,
'/usr/bin/vault-get-secret')


class TestCreateSharedServerSanitization(BaseTestGenerator):
"""Verify create_shared_server() strips sensitive
Expand Down Expand Up @@ -291,6 +339,7 @@ def test_no_session(self):
# Should not crash
result = ServerModule.get_shared_server_properties(
server, ss)
# SharedServer defaults passexec_cmd to None
self.assertIsNone(result.passexec_cmd)


Expand Down Expand Up @@ -457,6 +506,79 @@ def test_owner_deletes(self, mock_cu, mock_ck):
1, server.id)


class TestOwnerOnlyFieldsGuard(BaseTestGenerator):
"""Verify _set_valid_attr_value skips owner-only fields
for non-owners."""

scenarios = [
('Non-owner cannot set passexec_cmd',
dict(test_method='test_nonowner_passexec_blocked')),
('Owner can set passexec_cmd',
dict(test_method='test_owner_passexec_allowed')),
]

def runTest(self):
getattr(self, self.test_method)()

@patch(SRV_MODULE + '.get_crypt_key',
return_value=(True, b'key'))
@patch(SRV_MODULE + '.current_user')
def test_nonowner_passexec_blocked(self, mock_cu, mock_ck):
mock_cu.id = 200 # Non-owner
from pgadmin.browser.server_groups.servers import \
ServerNode

server = _make_server()
ss = _make_shared_server()
node = ServerNode.__new__(ServerNode)
node.delete_shared_server = MagicMock()

data = {
'passexec_cmd': '/evil/cmd',
'post_connection_sql': 'DROP TABLE x;',
}
config_map = {
'passexec_cmd': 'passexec_cmd',
'post_connection_sql': 'post_connection_sql',
}

node._set_valid_attr_value(
1, data, config_map, server, ss)

# SharedServer should NOT have these set
self.assertIsNone(ss.passexec_cmd)
self.assertIsNone(ss.post_connection_sql)

@patch(SRV_MODULE + '.get_crypt_key',
return_value=(True, b'key'))
@patch(SRV_MODULE + '.current_user')
def test_owner_passexec_allowed(self, mock_cu, mock_ck):
mock_cu.id = 100 # Owner
from pgadmin.browser.server_groups.servers import \
ServerNode

server = _make_server()
node = ServerNode.__new__(ServerNode)
node.delete_shared_server = MagicMock()

data = {
'passexec_cmd': '/usr/bin/new-cmd',
'post_connection_sql': 'SET role dba;',
}
config_map = {
'passexec_cmd': 'passexec_cmd',
'post_connection_sql': 'post_connection_sql',
}

node._set_valid_attr_value(
1, data, config_map, server, None)

# Owner should have these set
self.assertEqual(server.passexec_cmd, '/usr/bin/new-cmd')
self.assertEqual(
server.post_connection_sql, 'SET role dba;')


class TestGetSharedServerRaisesOnNone(BaseTestGenerator):
"""Verify get_shared_server() raises if SharedServer
cannot be created."""
Expand Down
7 changes: 7 additions & 0 deletions web/pgadmin/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,13 @@ class SharedServer(db.Model, UserScopedMixin):
shared = db.Column(db.Boolean(), nullable=False)
connection_params = db.Column(MutableDict.as_mutable(types.JSON))
prepare_threshold = db.Column(db.Integer(), nullable=True)
passexec_cmd = db.Column(db.Text(), nullable=True)
passexec_expiration = db.Column(db.Integer(), nullable=True)
kerberos_conn = db.Column(
db.Boolean(), nullable=False, default=0
)
tags = db.Column(types.JSON)
post_connection_sql = db.Column(db.String(), nullable=True)
Comment on lines +563 to +569
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Bump SCHEMA_VERSION with these new SharedServer columns.

This changes the config DB schema, but SCHEMA_VERSION is still 50 in this module. Existing installs that gate upgrades off that version can miss the migration and keep running against a sharedserver table that does not have these fields yet.

🔧 Suggested fix
-SCHEMA_VERSION = 50
+SCHEMA_VERSION = 51
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/model/__init__.py` around lines 563 - 569, The SCHEMA_VERSION
constant was not incremented after adding new SharedServer columns
(passexec_cmd, passexec_expiration, kerberos_conn, tags, post_connection_sql),
so update the module's SCHEMA_VERSION (e.g., from 50 to 51) where it is defined
so upgrades detect and run the migration for the SharedServer changes; ensure
the new version is used by migration/upgrade logic and any tests referencing
SCHEMA_VERSION are updated accordingly.



class Macros(db.Model):
Expand Down
Loading
Loading