Skip to content

Fix data isolation in server mode#9830

Open
asheshv wants to merge 3 commits intomasterfrom
fix/data-isolation-bugs
Open

Fix data isolation in server mode#9830
asheshv wants to merge 3 commits intomasterfrom
fix/data-isolation-bugs

Conversation

@asheshv
Copy link
Copy Markdown
Contributor

@asheshv asheshv commented Apr 8, 2026

Summary

  • Fix data isolation failures in server mode where multiple code paths loaded objects belonging to all users without filtering, allowing cross-user information disclosure
  • Add UserScopedMixin.for_user() to model layer as the standard pattern for user-scoped queries
  • Add multi-user data isolation tests

Changes

Data isolation fixes:

  • Add centralized get_accessible_server(), get_accessible_server_group(), get_server_groups_for_user(), get_user_server_query() helpers in utils/server_access.py
  • Replace ~30 unscoped Server.query.filter_by(id=sid) callsites with access-checked helper across servers, sqleditor, schema_diff, erd, psql, import_export, views, workspaces, debugger
  • Add access check in connection_manager() — the security boundary for all check_precondition-decorated endpoints
  • Scope ServerGroup.query.all() to current user + shared server groups
  • Add user_id to ServerGroup.properties() and nodes(gid) with shared group support
  • Replace or_(Server.user_id, Server.shared) listing patterns with get_user_server_query()
  • Add user_id to DebuggerFunctionArguments model (composite PK) with Alembic migration; SCHEMA_VERSION bumped to 50
  • Scope disconnect_from_all_servers(), delete_adhoc_servers(), get_servers_with_saved_passwords() to current user
  • Add indexes on server(user_id), server(servergroup_id), sharedserver(user_id), sharedserver(osid), servergroup(user_id)
  • Fix old migration ca00ec32581b to use raw SQL (model import broke on fresh databases)

UserScopedMixin:

  • New mixin for models with per-user data, providing Model.for_user(**kwargs) as the standard scoped query entry point
  • Handles both user_id and uid column naming conventions
  • Applied to 11 models: Setting, ServerGroup, Server, UserPreference, DebuggerFunctionArguments, Process, QueryHistoryModel, ApplicationState, SharedServer, UserMacros, UserMFA
  • Converted all Process.query.filter_by(user_id=...) callsites to Process.for_user()

Tests:

  • ServerGroupIsolationTestCase — non-admin cannot fetch another user's server group
  • ServerDataIsolationGetTestCase — non-admin cannot access another user's private server
  • SharedServerAccessTestCase — shared servers remain accessible cross-user (regression test)

Notes

  • Desktop mode is unaffected (single user, no isolation needed)
  • Administrator role bypasses isolation (consistent with existing permission model)
  • Server.shared=True means visible to all authenticated users; SharedServer records are for per-user customization, not access control
  • Tests require SERVER_MODE=True with pgAdmin4_test_non_admin_credentials configured

Test plan

  • Existing regression suite passes (1161 passed, 126 pre-existing failures, 0 regressions)
  • Manual smoke test: shared server tree expansion in server mode
  • Manual smoke test: non-admin user cannot see admin's private servers
  • Verify Alembic migration on fresh SQLite database
  • Verify Alembic migration on existing PostgreSQL config database

Summary by CodeRabbit

  • New Features

    • Centralized server/group access control; UI now enforces per-user visibility and returns "not found" for inaccessible resources.
    • Debugger arguments, background processes, and server-scoped utilities now operate in a per-user scope.
  • Bug Fixes

    • Prevented cross-user visibility and unintended access to private servers, groups, adhoc resources, and credentials.
  • Tests

    • Added regression tests validating server/group data isolation and shared-server access.
  • Chores

    • Schema version bumped and migrations added to persist user-scoped data.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 8, 2026

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f0c5e599-1e01-4a2f-bc2a-7958628a9684

📥 Commits

Reviewing files that changed from the base of the PR and between 35a46de and f097f70.

📒 Files selected for processing (27)
  • web/migrations/versions/add_user_id_to_debugger_func_args_.py
  • web/migrations/versions/ca00ec32581b_.py
  • web/pgadmin/browser/server_groups/__init__.py
  • web/pgadmin/browser/server_groups/servers/__init__.py
  • web/pgadmin/browser/server_groups/servers/databases/__init__.py
  • web/pgadmin/browser/server_groups/servers/databases/schemas/views/__init__.py
  • web/pgadmin/browser/server_groups/servers/tests/test_server_data_isolation.py
  • web/pgadmin/browser/server_groups/servers/utils.py
  • web/pgadmin/browser/server_groups/tests/test_sg_data_isolation.py
  • web/pgadmin/misc/bgprocess/processes.py
  • web/pgadmin/misc/cloud/__init__.py
  • web/pgadmin/misc/workspaces/__init__.py
  • web/pgadmin/model/__init__.py
  • web/pgadmin/tools/backup/tests/test_batch_process.py
  • web/pgadmin/tools/debugger/__init__.py
  • web/pgadmin/tools/erd/__init__.py
  • web/pgadmin/tools/import_export/__init__.py
  • web/pgadmin/tools/import_export/tests/test_batch_process.py
  • web/pgadmin/tools/maintenance/tests/test_batch_process_maintenance.py
  • web/pgadmin/tools/psql/__init__.py
  • web/pgadmin/tools/restore/tests/test_batch_process.py
  • web/pgadmin/tools/schema_diff/__init__.py
  • web/pgadmin/tools/sqleditor/__init__.py
  • web/pgadmin/tools/user_management/__init__.py
  • web/pgadmin/utils/__init__.py
  • web/pgadmin/utils/driver/psycopg3/__init__.py
  • web/pgadmin/utils/server_access.py
 _________________________________________________________________
< This is Sparta! And I'm here to kick bugs into the pit of doom. >
 -----------------------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).

Walkthrough

Centralized server/group access-control added via new server_access utilities; models gain a UserScopedMixin and DebuggerFunctionArguments adds a user_id PK; migrations updated; many modules refactored to use access helpers and to scope queries/processes to the current user; tests adjusted and new isolation tests added.

Changes

Cohort / File(s) Summary
Access control core & models
web/pgadmin/utils/server_access.py, web/pgadmin/model/__init__.py
Added server access utilities (get_server, get_server_group, get_user_server_query, etc.), _is_admin(), get_server_groups_for_user(); introduced UserScopedMixin.for_user(); bumped SCHEMA_VERSION 49→50; many models now inherit UserScopedMixin; DebuggerFunctionArguments adds user_id PK/FK.
Migrations
web/migrations/versions/add_user_id_to_debugger_func_args_.py, web/migrations/versions/ca00ec32581b_.py
New migration to add/backfill user_id into debugger_function_arguments and update composite PK with conditional SQLite handling and conditional index creation; another migration simplified deletion to raw DELETE SQL.
Server/group browser & server nodes
web/pgadmin/browser/server_groups/..., web/pgadmin/browser/server_groups/servers/..., web/pgadmin/browser/server_groups/servers/utils.py
Replaced many direct ORM lookups with get_server, get_server_group, and get_user_server_query(); tightened ownership checks for sensitive ops; adjusted node listing and server-group retrieval to use access helpers and respect sharing.
Tools & editors
web/pgadmin/tools/debugger/__init__.py, .../erd/__init__.py, .../import_export/__init__.py, .../psql/__init__.py, .../schema_diff/__init__.py, .../sqleditor/__init__.py, web/pgadmin/tools/user_management/__init__.py
Server resolution switched to access helpers; handlers now return HTTP 410 when servers are inaccessible; server/group listing and connection flows use user-scoped queries; get_server(..., only_owned=...) used where write ownership enforced.
Background processes, cloud & workspaces
web/pgadmin/misc/bgprocess/processes.py, web/pgadmin/misc/cloud/__init__.py, web/pgadmin/misc/workspaces/__init__.py
Replaced per-user Process.query.filter_by(...) with Process.for_user(...); cloud/workspace handlers now guard by accessible servers/processes and early-return when none found; added more robust exception handling in disconnect loops.
Driver & utils
web/pgadmin/utils/__init__.py, web/pgadmin/utils/driver/psycopg3/__init__.py
get_server() delegates to server_access and gains only_owned argument; psycopg3 connection manager enforces server-mode access and uses resolved server_data when constructing ServerManager.
Tests & test adjustments
web/pgadmin/browser/server_groups/servers/tests/test_server_data_isolation.py, web/pgadmin/browser/server_groups/tests/test_sg_data_isolation.py, web/pgadmin/tools/*/tests/*
Added server/group data-isolation tests for server-mode; updated batch-process tests to mock process.for_user along with query.filter_by.
Misc small updates
various files (.../databases/..., .../schemas/views/..., tools/*, etc.)
Multiple modules updated to use get_server(...), get_server_groups_for_user(), or get_user_server_query() with minor control-flow adjustments to fail fast when server inaccessible.

Sequence Diagram

sequenceDiagram
    participant Client as User Request
    participant Endpoint as Endpoint Handler
    participant Access as get_server / server_access
    participant DB as SQLAlchemy / Models
    participant Response as HTTP Response

    Client->>Endpoint: request (e.g., GET /browser/server/obj/{sid})
    Endpoint->>Access: get_server(sid, only_owned?)
    Access->>DB: query Server/ServerGroup with access rules
    DB-->>Access: Server object or None
    alt Server returned
        Access->>Endpoint: Server object
        Endpoint->>Response: 200 OK + data
    else None returned
        Access->>Endpoint: None
        Endpoint->>Response: 410 Gone + error
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 59.41% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Fix data isolation in server mode' is concise, clear, and directly summarizes the primary objective of the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/data-isolation-bugs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@asheshv asheshv force-pushed the fix/data-isolation-bugs branch from b913595 to 2e59998 Compare April 8, 2026 10:27
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
web/pgadmin/tools/schema_diff/__init__.py (1)

373-390: ⚠️ Potential issue | 🔴 Critical

Check accessibility before touching the connection manager.

Lines 375-383 still probe connection_manager(sid) and conn.connected() before the new guard on Line 385. That puts the security check behind a manager lookup/connection-state probe, so unauthorized requests can bypass the intended 410 path for already-connected servers or fail upstream before the access check runs.

🔒 Suggested fix
 def connect_server(sid):
     # Check if server is already connected then no need to reconnect again.
-    driver = get_driver(PG_DEFAULT_DRIVER)
-    manager = driver.connection_manager(sid)
-    conn = manager.connection()
-    if conn.connected():
-        return make_json_response(
-            success=1,
-            info=gettext("Server connected."),
-            data={}
-        )
-
     server = get_accessible_server(sid)
     if server is None:
         return make_json_response(
             status=410, success=0,
             errormsg=gettext("Could not find the required server.")
         )
+    driver = get_driver(PG_DEFAULT_DRIVER)
+    manager = driver.connection_manager(sid)
+    conn = manager.connection()
+    if conn.connected():
+        return make_json_response(
+            success=1,
+            info=gettext("Server connected."),
+            data={}
+        )
     view = SchemaDiffRegistry.get_node_view('server')
     return view.connect(server.servergroup_id, sid)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/tools/schema_diff/__init__.py` around lines 373 - 390, The
connect_server function currently calls get_driver/get_connection_manager and
inspects conn.connected() before verifying access; move the access check so
get_accessible_server(sid) is called first and returns the 410 path if None
before any calls to get_driver, manager, or conn. In practice, in
connect_server, call get_accessible_server(sid) and handle the None case
immediately, then proceed to get_driver(PG_DEFAULT_DRIVER), manager =
driver.connection_manager(sid) and conn = manager.connection() and the existing
connected() logic.
web/pgadmin/browser/server_groups/servers/__init__.py (1)

2167-2174: ⚠️ Potential issue | 🔴 Critical

Clear the current user’s shared-server tunnel secret, not the owner’s.

For shared servers, get_accessible_server(sid) still returns the base Server row. Clearing server.tunnel_password here wipes the owner’s saved SSH tunnel password for every user of that shared server. clear_saved_password() right above already has the correct SharedServer branch; this path needs the same treatment.

🔐 Suggested fix
     def clear_sshtunnel_password(self, gid, sid):
         """
         This function is used to remove sshtunnel password stored into
         the pgAdmin's db file.
@@
         :return:
         """
         try:
             server = get_accessible_server(sid)
+            shared_server = None
             if server is None:
                 return make_json_response(
                     success=0,
                     info=self.not_found_error_msg()
                 )
-            setattr(server, 'tunnel_password', None)
+            if server.shared and server.user_id != current_user.id:
+                shared_server = SharedServer.query.filter_by(
+                    name=server.name, user_id=current_user.id,
+                    servergroup_id=gid, osid=server.id
+                ).first()
+                if shared_server is None:
+                    return make_json_response(
+                        success=0,
+                        info=gettext("Could not find the required server.")
+                    )
+                setattr(shared_server, 'tunnel_password', None)
+            else:
+                setattr(server, 'tunnel_password', None)
             db.session.commit()
🤖 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 2167 -
2174, The code is clearing the owner Server.tunnel_password for shared servers;
instead, detect when the accessible server is a shared instance and clear the
SharedServer row for the current user. Modify the branch after
get_accessible_server(sid) to check for a SharedServer (or query SharedServer by
server_id and current_user.id), set that SharedServer's tunnel_password = None
and commit; otherwise keep the existing behavior of clearing
Server.tunnel_password for non-shared servers. Use the same pattern/logic as
clear_saved_password() and ensure db.session.commit() is called after updating
the correct record.
web/pgadmin/tools/sqleditor/__init__.py (1)

2412-2434: ⚠️ Potential issue | 🟠 Major

Apply per-user SharedServer overrides before building server_list.

This route now scopes which rows are returned, but it still serializes raw Server objects. For shared servers, non-owners will get the owner-side name, username, colors, and connection_params in the new-connection dialog instead of their SharedServer values. That breaks the per-user customization model and can expose owner-specific connection metadata.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/tools/sqleditor/__init__.py` around lines 2412 - 2434, The loop
currently serializes raw Server rows (from get_user_server_query) into
server_group_data, which ignores per-user SharedServer overrides; fetch the
SharedServer override for the current user for each Server (e.g. via
SharedServer.query.filter_by(server_id=server.id, user_id=current_user.id) or an
existing helper) and apply its override fields (name, username, fgcolor,
bgcolor, connection_params, host/port/service as present) before calling
server_icon_and_background and before building the dict; ensure
convert_connection_parameter is called on the overridden connection_params and
use the overridden values when populating 'label', 'value',
'fgcolor','bgcolor','host','port','service','connection_params' and when
computing 'image' so the per-user customizations are reflected in
server_group_data.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@web/migrations/versions/add_user_id_to_debugger_func_args_.py`:
- Around line 102-118: The migration currently swallows all errors when running
op.execute over index_stmts, hiding real failures; change the error handling
around op.execute so only "table does not exist" (or equivalent message from the
DB) is ignored and any other exception is re-raised. Update the loop that
iterates index_stmts calling op.execute (refer to index_stmts and op.execute) to
catch the exception into a variable, inspect the error message/type (e.g.,
sqlalchemy.exc.* or check "does not exist" in str(e).lower()), continue only for
missing-table errors, and re-raise for any other error so the migration fails
loudly.

In
`@web/pgadmin/browser/server_groups/servers/databases/schemas/views/__init__.py`:
- Line 2321: MViewNode.check_utility_exists() is still doing an owner-only
server lookup while refresh_data() accepts shared servers; update the precheck
to use the same access helper by replacing the hardcoded owner-only lookup with
get_accessible_server(sid) (the same helper used in refresh_data()), ensuring
the check obtains the accessible server object and then proceeds with the
existing utility existence logic.

In `@web/pgadmin/browser/server_groups/servers/utils.py`:
- Around line 695-698: The sid branch of delete_adhoc_servers currently only
filters by Server.id (and optionally Server.user_id), risking deletion of
persisted servers; update the query built in delete_adhoc_servers so that when
filtering by sid you also add Server.is_adhoc == 1 to the query (i.e., refine q
= db.session.query(Server).filter(Server.id == sid) to include Server.is_adhoc
== 1, and preserve the existing optional filter on Server.user_id ==
current_user.id before calling q.delete()).

In `@web/pgadmin/tools/import_export/__init__.py`:
- Line 297: IEMessage.get_server_name() currently uses an owner-only server
lookup causing shared servers to appear as "Not available"; change its server
lookup to use the same access-aware helper (get_accessible_server(sid)) used
elsewhere so shared servers are returned. Locate the IEMessage.get_server_name
method and replace the owner-restricted query with a call to
get_accessible_server(sid) (or refactor to delegate to that helper) and ensure
any subsequent null/None handling remains consistent.

In `@web/pgadmin/tools/sqleditor/__init__.py`:
- Around line 522-526: _init_sqleditor() returns a bare Response when
get_accessible_server(sid) is None, but callers (initialize_sqleditor,
update_sqleditor_connection, check_and_upgrade_to_qt) expect to unpack four
values; change the not-found branch to return the same 4-tuple shape (e.g.
return internal_server_error(...), None, None, None) so the callers can safely
unpack the response plus the other values; update the branch that uses
get_accessible_server(sid) to return the Response as the first element and
placeholders (None) for the remaining three.

---

Outside diff comments:
In `@web/pgadmin/browser/server_groups/servers/__init__.py`:
- Around line 2167-2174: The code is clearing the owner Server.tunnel_password
for shared servers; instead, detect when the accessible server is a shared
instance and clear the SharedServer row for the current user. Modify the branch
after get_accessible_server(sid) to check for a SharedServer (or query
SharedServer by server_id and current_user.id), set that SharedServer's
tunnel_password = None and commit; otherwise keep the existing behavior of
clearing Server.tunnel_password for non-shared servers. Use the same
pattern/logic as clear_saved_password() and ensure db.session.commit() is called
after updating the correct record.

In `@web/pgadmin/tools/schema_diff/__init__.py`:
- Around line 373-390: The connect_server function currently calls
get_driver/get_connection_manager and inspects conn.connected() before verifying
access; move the access check so get_accessible_server(sid) is called first and
returns the 410 path if None before any calls to get_driver, manager, or conn.
In practice, in connect_server, call get_accessible_server(sid) and handle the
None case immediately, then proceed to get_driver(PG_DEFAULT_DRIVER), manager =
driver.connection_manager(sid) and conn = manager.connection() and the existing
connected() logic.

In `@web/pgadmin/tools/sqleditor/__init__.py`:
- Around line 2412-2434: The loop currently serializes raw Server rows (from
get_user_server_query) into server_group_data, which ignores per-user
SharedServer overrides; fetch the SharedServer override for the current user for
each Server (e.g. via SharedServer.query.filter_by(server_id=server.id,
user_id=current_user.id) or an existing helper) and apply its override fields
(name, username, fgcolor, bgcolor, connection_params, host/port/service as
present) before calling server_icon_and_background and before building the dict;
ensure convert_connection_parameter is called on the overridden
connection_params and use the overridden values when populating 'label',
'value', 'fgcolor','bgcolor','host','port','service','connection_params' and
when computing 'image' so the per-user customizations are reflected in
server_group_data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 92206711-07e0-4821-9336-efaba5d30128

📥 Commits

Reviewing files that changed from the base of the PR and between d59fcf3 and b913595.

📒 Files selected for processing (23)
  • web/migrations/versions/add_user_id_to_debugger_func_args_.py
  • web/migrations/versions/ca00ec32581b_.py
  • web/pgadmin/browser/server_groups/__init__.py
  • web/pgadmin/browser/server_groups/servers/__init__.py
  • web/pgadmin/browser/server_groups/servers/databases/__init__.py
  • web/pgadmin/browser/server_groups/servers/databases/schemas/views/__init__.py
  • web/pgadmin/browser/server_groups/servers/tests/test_server_data_isolation.py
  • web/pgadmin/browser/server_groups/servers/utils.py
  • web/pgadmin/browser/server_groups/tests/test_sg_data_isolation.py
  • web/pgadmin/misc/bgprocess/processes.py
  • web/pgadmin/misc/cloud/__init__.py
  • web/pgadmin/misc/workspaces/__init__.py
  • web/pgadmin/model/__init__.py
  • web/pgadmin/tools/debugger/__init__.py
  • web/pgadmin/tools/erd/__init__.py
  • web/pgadmin/tools/import_export/__init__.py
  • web/pgadmin/tools/psql/__init__.py
  • web/pgadmin/tools/schema_diff/__init__.py
  • web/pgadmin/tools/sqleditor/__init__.py
  • web/pgadmin/tools/user_management/__init__.py
  • web/pgadmin/utils/__init__.py
  • web/pgadmin/utils/driver/psycopg3/__init__.py
  • web/pgadmin/utils/server_access.py

@asheshv asheshv force-pushed the fix/data-isolation-bugs branch from 2e59998 to b5b16d8 Compare April 8, 2026 10:36
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
web/pgadmin/tools/user_management/__init__.py (1)

750-772: ⚠️ Potential issue | 🟠 Major

Delete debugger argument rows before deleting the user.

The new DebuggerFunctionArguments.user_id foreign key means this cleanup block now needs to remove those rows as well. Otherwise a user who has used the debugger can fail deletion on commit, or keep orphaned config state on backends without FK enforcement.

🧹 Minimal fix
-from pgadmin.model import db, Role, User, UserPreference, Server, \
-    ServerGroup, Process, Setting, roles_users, SharedServer
+from pgadmin.model import db, Role, User, UserPreference, Server, \
+    ServerGroup, Process, Setting, roles_users, SharedServer, \
+    DebuggerFunctionArguments
@@
         Process.for_user(user_id=uid).delete()
+        DebuggerFunctionArguments.for_user(user_id=uid).delete()
         # Delete Shared servers for current user.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/tools/user_management/__init__.py` around lines 750 - 772, The
cleanup block must also delete DebuggerFunctionArguments rows tied to the user
to avoid FK errors or orphaned state; add a call like
DebuggerFunctionArguments.query.filter_by(user_id=uid).delete(synchronize_session=False)
(or equivalent) alongside the other deletes (e.g., near
Process.for_user(user_id=uid).delete() / SharedServer deletes) before
db.session.delete(usr) and db.session.commit(); reference the
DebuggerFunctionArguments model and its user_id FK when placing the new delete.
web/pgadmin/browser/server_groups/servers/databases/__init__.py (1)

553-609: ⚠️ Potential issue | 🟠 Major

Guard connection_manager() returning None.

After the new server-access check in connection_manager(), these endpoints can now get None for an unauthorized or missing server. Both methods dereference manager immediately, which turns an access denial into a 500 instead of the handled not-found response used by check_precondition().

🛡️ Suggested guard
     def connect(self, gid, sid, did):
         """Connect the Database."""
         from pgadmin.utils.driver import get_driver
         manager = get_driver(PG_DEFAULT_DRIVER).connection_manager(sid)
+        if manager is None:
+            return gone(errormsg=_("Could not find the server."))
         conn = manager.connection(did=did, auto_reconnect=True)
@@
     def disconnect(self, gid, sid, did):
         """Disconnect the database."""
 
         # Release Connection
         from pgadmin.utils.driver import get_driver
         manager = get_driver(PG_DEFAULT_DRIVER).connection_manager(sid)
+        if manager is None:
+            return gone(errormsg=_("Could not find the server."))
         conn = manager.connection(did=did, auto_reconnect=True)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/browser/server_groups/servers/databases/__init__.py` around lines
553 - 609, Both connect() and disconnect() assume
get_driver(...).connection_manager(sid) returns a manager and dereference it
immediately; after the new server-access check connection_manager() may return
None causing a 500. Add a guard after obtaining manager in both connect() and
disconnect(): if manager is None, return the same handled response used by
check_precondition() (i.e., call/check_precondition or the equivalent
not-found/unauthorized response) instead of proceeding to use manager; reference
the connection_manager call and the manager variable in connect() and
disconnect() to locate where to add this early return.
web/pgadmin/misc/workspaces/__init__.py (1)

164-178: ⚠️ Potential issue | 🟠 Major

Clone the current user’s shared-server view, not the base server row.

This branch can now clone shared servers, but server.clone() still copies the owner Server record. The adhoc server will lose per-user SharedServer settings such as name, service/SSH fields, saved passwords, and passfile/SSL overrides unless the shared overlay is applied before cloning.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/misc/workspaces/__init__.py` around lines 164 - 178, The code
currently calls get_accessible_server(...) then server.clone(), which clones the
base Server row and loses per-user SharedServer overlays; instead fetch the
user's SharedServer overlay for the accessible server (e.g., via the
SharedServer model lookup for the current user and server id), merge/apply that
SharedServer overlay onto the server object (copying fields like name,
service/ssh, saved_passwords, passfile/ssl overrides) and only then call
server.clone() (or clone from the composed object) so the adhoc server preserves
per-user SharedServer settings; update the logic around get_accessible_server,
server.clone(), and where connection_params/username/role/is_adhoc are set to
operate on the overlay-merged server instance.
web/pgadmin/tools/import_export/__init__.py (1)

99-110: ⚠️ Potential issue | 🟠 Major

Resolve the caller’s SharedServer before formatting or launching the job.

get_accessible_server() only checks visibility. On shared servers, this code still reads the owner row for get_server_name(), env['PGUSER'], and p.set_env_variables(server, ...), so import/export jobs can run with the wrong username/passfile/SSL overrides and show the wrong server label. Materialize the current user’s SharedServer overlay here before using server.

Also applies to: 295-303, 389-403

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/tools/import_export/__init__.py` around lines 99 - 110,
get_accessible_server() only checks visibility but does not apply per-user
SharedServer overlays, so get_server_name() and subsequent job setup
(env['PGUSER'], p.set_env_variables(server, ...)) can use the owner/server row
instead of the current user's overlay; resolve the caller’s SharedServer overlay
before formatting or launching the job by replacing the raw server object
returned from get_accessible_server(self.sid) with the per-user
SharedServer-resolved server (use the SharedServer resolution helper or query to
materialize the overlay) in get_server_name() and in the places that set
env['PGUSER'] and call p.set_env_variables(server, ...) so that username,
passfile and SSL overrides come from the caller’s SharedServer overlay.
web/pgadmin/tools/sqleditor/__init__.py (1)

230-239: ⚠️ Potential issue | 🟠 Major

Shared-server overrides are still skipped in two SQL Editor paths.

initialize_viewdata() reads server.password/name/username directly, and get_new_connection_data() serializes name, colors, service, and connection_params from the base Server row. Shared users will therefore reconnect/prompt against owner-side credentials and see owner-side metadata in the new-connection dialog. Reuse the same shared-server resolution block that _init_sqleditor() and panel() already use.

Also applies to: 259-265, 2413-2435

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/tools/sqleditor/__init__.py` around lines 230 - 239, The code in
initialize_viewdata() and get_new_connection_data() is directly reading
Server.row fields (password/name/username) and must instead reuse the
shared-server resolution logic used by _init_sqleditor() and panel(); locate the
shared-server resolution block (the get_accessible_server(sid) + shared-owner
override logic) and apply it where initialize_viewdata() and
get_new_connection_data() pull server.password/name/username so the resolved
Server (not the raw owner row) is used; update these functions to call the same
resolution helper (or inline the same block) and then set kwargs['encpass'] (and
any name/username values) from the resolved server object, repeating the change
for the other occurrences noted (around the other ranges mentioned).
🧹 Nitpick comments (3)
web/pgadmin/tools/restore/tests/test_batch_process.py (1)

137-138: Mock Process.for_user() directly.

Aliasing for_user to query.filter_by lets a regression back to Process.query.filter_by(...) keep passing, so this no longer proves the scoped API is being used on this security-sensitive path. The same pattern is repeated in the backup and maintenance batch-process tests.

♻️ Suggested test setup
-        process_mock.for_user = process_mock.query.filter_by
-        mock_result = process_mock.query.filter_by.return_value
+        mock_result = process_mock.for_user.return_value
         mock_result.first.return_value = TestMockProcess(
             restore_obj, self.class_params['args'],
             self.class_params['cmd'])
@@
         popen_mock.side_effect = popenMockSideEffect
         p.start()

+        process_mock.for_user.assert_called()
         self.assertTrue(popen_mock.called)
-        process_mock.for_user = process_mock.query.filter_by
-        process_mock.query.filter_by.return_value = [
+        process_mock.for_user.return_value = [
             TestMockProcess(restore_obj,
                             self.class_params['args'],
                             self.class_params['cmd'])
         ]
@@
         update_process_info_mock.return_value = [True, True]

         ret_value = p.list()
+        process_mock.for_user.assert_called()
         self.assertEqual(1, len(ret_value))

Also applies to: 178-179

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/tools/restore/tests/test_batch_process.py` around lines 137 -
138, The test currently aliases Process.for_user to Process.query.filter_by (via
process_mock.for_user = process_mock.query.filter_by) which masks regressions;
instead mock the scoped API directly by setting process_mock.for_user =
mock.Mock(return_value=mock_result) or by patching Process.for_user to return
mock_result so assertions exercise the for_user call (replace the aliasing at
the process_mock.for_user assignment and the similar setup at lines 178-179);
ensure any assertions verify process_mock.for_user(...) was called rather than
query.filter_by.
web/pgadmin/browser/server_groups/servers/tests/test_server_data_isolation.py (2)

57-58: Prefer self.fail(...) over raising generic Exception in tests.

At Line 57-58 and Line 107-108, use unittest-native failure reporting for clearer test output.

Suggested fix
-        if not self.server_id:
-            raise Exception("Server not found to test isolation")
+        if not self.server_id:
+            self.fail("Server not found to test isolation")
...
-        if not self.server_id:
-            raise Exception("Server not found to test shared access")
+        if not self.server_id:
+            self.fail("Server not found to test shared access")

Also applies to: 107-108

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@web/pgadmin/browser/server_groups/servers/tests/test_server_data_isolation.py`
around lines 57 - 58, The test uses raise Exception("Server not found to test
isolation") (and the similar raise at the other location) which should be
replaced with the unittest TestCase failure helper to produce proper test
output; update the test methods that check self.server_id (and the second
identical check) to call self.fail("Server not found to test isolation") instead
of raising Exception so the failure is reported by the test runner (look for
uses of self.server_id in test_server_data_isolation tests).

31-34: Use immutable scenarios to avoid RUF012 mutable-class-attribute warnings.

At Line 31-34 and Line 82-85, scenarios can be tuples since they are static definitions.

Suggested fix
-    scenarios = [
+    scenarios = (
         ('User B gets 410 for User A private server',
          dict(is_positive_test=False)),
-    ]
+    )
...
-    scenarios = [
+    scenarios = (
         ('User B can access shared server from User A',
          dict(is_positive_test=True)),
-    ]
+    )

Also applies to: 82-85

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@web/pgadmin/browser/server_groups/servers/tests/test_server_data_isolation.py`
around lines 31 - 34, Change the mutable list assigned to the variable scenarios
into an immutable tuple in this test file: replace the list literal for
scenarios (e.g., scenarios = [ ('User B gets 410 for User A private server',
dict(is_positive_test=False)), ]) with a tuple literal (e.g., scenarios = ( ...
,)) at both occurrences (the one around lines 31-34 and the one around lines
82-85) so the static test definitions are immutable; keep the same tuple
elements and structure, only change the enclosing brackets from [] to ().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@web/pgadmin/browser/server_groups/servers/__init__.py`:
- Around line 1820-1823: The credential endpoints currently call
get_accessible_server(sid) but still update the base Server row, which mutates
owners' credentials for shared servers; update change_password() and
clear_sshtunnel_password() to follow the same selection logic used in
clear_saved_password(): detect when the accessible server is a SharedServer
instance and perform updates against the caller's SharedServer record (not the
base Server), ensuring you load or create the SharedServer for the current user
and mutate its password fields (SharedServer.password /
SharedServer.ssh_tunnel_password) and save that object instead of Server;
reference the functions get_accessible_server(), change_password(),
clear_sshtunnel_password(), clear_saved_password(), and the SharedServer model
when making the change.

In
`@web/pgadmin/browser/server_groups/servers/tests/test_server_data_isolation.py`:
- Around line 45-52: The test currently dereferences
response_data['node']['_id'] immediately after posting (via self.tester.post)
which can raise a noisy exception if creation failed; before assigning
self.server_id, assert the HTTP response indicates success (check
response.status_code or response.status), assert response.data decodes to JSON
and that 'node' and '_id' keys exist (e.g. assert 'node' in response_data and
'_id' in response_data['node']), and only then set self.server_id =
response_data['node']['_id']; apply the same validation/checks to both
occurrences where response_data['node']['_id'] is used so failures produce clear
assertion messages instead of KeyError/TypeError.

In `@web/pgadmin/tools/erd/__init__.py`:
- Line 560: The call site guarding only panel rendering is insufficient; update
the backend path that dereferences connections so unauthorized requests don't
500 by adding a None check after connection_manager(sid) inside _get_connection
(and any callers like the /initialize, /prerequisite, /sql handlers and socket
handlers that use _get_connection). Specifically, detect when
connection_manager(sid) returns None, and return/raise an appropriate HTTP error
(e.g., 403 Forbidden or abort with a JSON error) instead of continuing to
dereference the object; ensure helpers like get_accessible_server remain
unchanged and that _get_connection consistently enforces the access boundary.

---

Outside diff comments:
In `@web/pgadmin/browser/server_groups/servers/databases/__init__.py`:
- Around line 553-609: Both connect() and disconnect() assume
get_driver(...).connection_manager(sid) returns a manager and dereference it
immediately; after the new server-access check connection_manager() may return
None causing a 500. Add a guard after obtaining manager in both connect() and
disconnect(): if manager is None, return the same handled response used by
check_precondition() (i.e., call/check_precondition or the equivalent
not-found/unauthorized response) instead of proceeding to use manager; reference
the connection_manager call and the manager variable in connect() and
disconnect() to locate where to add this early return.

In `@web/pgadmin/misc/workspaces/__init__.py`:
- Around line 164-178: The code currently calls get_accessible_server(...) then
server.clone(), which clones the base Server row and loses per-user SharedServer
overlays; instead fetch the user's SharedServer overlay for the accessible
server (e.g., via the SharedServer model lookup for the current user and server
id), merge/apply that SharedServer overlay onto the server object (copying
fields like name, service/ssh, saved_passwords, passfile/ssl overrides) and only
then call server.clone() (or clone from the composed object) so the adhoc server
preserves per-user SharedServer settings; update the logic around
get_accessible_server, server.clone(), and where
connection_params/username/role/is_adhoc are set to operate on the
overlay-merged server instance.

In `@web/pgadmin/tools/import_export/__init__.py`:
- Around line 99-110: get_accessible_server() only checks visibility but does
not apply per-user SharedServer overlays, so get_server_name() and subsequent
job setup (env['PGUSER'], p.set_env_variables(server, ...)) can use the
owner/server row instead of the current user's overlay; resolve the caller’s
SharedServer overlay before formatting or launching the job by replacing the raw
server object returned from get_accessible_server(self.sid) with the per-user
SharedServer-resolved server (use the SharedServer resolution helper or query to
materialize the overlay) in get_server_name() and in the places that set
env['PGUSER'] and call p.set_env_variables(server, ...) so that username,
passfile and SSL overrides come from the caller’s SharedServer overlay.

In `@web/pgadmin/tools/sqleditor/__init__.py`:
- Around line 230-239: The code in initialize_viewdata() and
get_new_connection_data() is directly reading Server.row fields
(password/name/username) and must instead reuse the shared-server resolution
logic used by _init_sqleditor() and panel(); locate the shared-server resolution
block (the get_accessible_server(sid) + shared-owner override logic) and apply
it where initialize_viewdata() and get_new_connection_data() pull
server.password/name/username so the resolved Server (not the raw owner row) is
used; update these functions to call the same resolution helper (or inline the
same block) and then set kwargs['encpass'] (and any name/username values) from
the resolved server object, repeating the change for the other occurrences noted
(around the other ranges mentioned).

In `@web/pgadmin/tools/user_management/__init__.py`:
- Around line 750-772: The cleanup block must also delete
DebuggerFunctionArguments rows tied to the user to avoid FK errors or orphaned
state; add a call like
DebuggerFunctionArguments.query.filter_by(user_id=uid).delete(synchronize_session=False)
(or equivalent) alongside the other deletes (e.g., near
Process.for_user(user_id=uid).delete() / SharedServer deletes) before
db.session.delete(usr) and db.session.commit(); reference the
DebuggerFunctionArguments model and its user_id FK when placing the new delete.

---

Nitpick comments:
In
`@web/pgadmin/browser/server_groups/servers/tests/test_server_data_isolation.py`:
- Around line 57-58: The test uses raise Exception("Server not found to test
isolation") (and the similar raise at the other location) which should be
replaced with the unittest TestCase failure helper to produce proper test
output; update the test methods that check self.server_id (and the second
identical check) to call self.fail("Server not found to test isolation") instead
of raising Exception so the failure is reported by the test runner (look for
uses of self.server_id in test_server_data_isolation tests).
- Around line 31-34: Change the mutable list assigned to the variable scenarios
into an immutable tuple in this test file: replace the list literal for
scenarios (e.g., scenarios = [ ('User B gets 410 for User A private server',
dict(is_positive_test=False)), ]) with a tuple literal (e.g., scenarios = ( ...
,)) at both occurrences (the one around lines 31-34 and the one around lines
82-85) so the static test definitions are immutable; keep the same tuple
elements and structure, only change the enclosing brackets from [] to ().

In `@web/pgadmin/tools/restore/tests/test_batch_process.py`:
- Around line 137-138: The test currently aliases Process.for_user to
Process.query.filter_by (via process_mock.for_user =
process_mock.query.filter_by) which masks regressions; instead mock the scoped
API directly by setting process_mock.for_user =
mock.Mock(return_value=mock_result) or by patching Process.for_user to return
mock_result so assertions exercise the for_user call (replace the aliasing at
the process_mock.for_user assignment and the similar setup at lines 178-179);
ensure any assertions verify process_mock.for_user(...) was called rather than
query.filter_by.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bbd03b26-818f-4e1b-8abd-d3ac1fc81307

📥 Commits

Reviewing files that changed from the base of the PR and between b913595 and b5b16d8.

📒 Files selected for processing (27)
  • web/migrations/versions/add_user_id_to_debugger_func_args_.py
  • web/migrations/versions/ca00ec32581b_.py
  • web/pgadmin/browser/server_groups/__init__.py
  • web/pgadmin/browser/server_groups/servers/__init__.py
  • web/pgadmin/browser/server_groups/servers/databases/__init__.py
  • web/pgadmin/browser/server_groups/servers/databases/schemas/views/__init__.py
  • web/pgadmin/browser/server_groups/servers/tests/test_server_data_isolation.py
  • web/pgadmin/browser/server_groups/servers/utils.py
  • web/pgadmin/browser/server_groups/tests/test_sg_data_isolation.py
  • web/pgadmin/misc/bgprocess/processes.py
  • web/pgadmin/misc/cloud/__init__.py
  • web/pgadmin/misc/workspaces/__init__.py
  • web/pgadmin/model/__init__.py
  • web/pgadmin/tools/backup/tests/test_batch_process.py
  • web/pgadmin/tools/debugger/__init__.py
  • web/pgadmin/tools/erd/__init__.py
  • web/pgadmin/tools/import_export/__init__.py
  • web/pgadmin/tools/import_export/tests/test_batch_process.py
  • web/pgadmin/tools/maintenance/tests/test_batch_process_maintenance.py
  • web/pgadmin/tools/psql/__init__.py
  • web/pgadmin/tools/restore/tests/test_batch_process.py
  • web/pgadmin/tools/schema_diff/__init__.py
  • web/pgadmin/tools/sqleditor/__init__.py
  • web/pgadmin/tools/user_management/__init__.py
  • web/pgadmin/utils/__init__.py
  • web/pgadmin/utils/driver/psycopg3/__init__.py
  • web/pgadmin/utils/server_access.py
🚧 Files skipped from review as they are similar to previous changes (10)
  • web/pgadmin/misc/cloud/init.py
  • web/pgadmin/tools/psql/init.py
  • web/migrations/versions/ca00ec32581b_.py
  • web/pgadmin/utils/init.py
  • web/pgadmin/misc/bgprocess/processes.py
  • web/pgadmin/browser/server_groups/servers/databases/schemas/views/init.py
  • web/pgadmin/tools/debugger/init.py
  • web/migrations/versions/add_user_id_to_debugger_func_args_.py
  • web/pgadmin/tools/schema_diff/init.py
  • web/pgadmin/browser/server_groups/init.py

is_linux_platform = True

s = Server.query.filter_by(id=int(params['sid'])).first()
s = get_accessible_server(int(params['sid']))
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Apr 8, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

This only protects the panel render.

get_accessible_server() here blocks the initial HTML load, but the direct ERD routes still flow through _get_connection(), which dereferences connection_manager(sid) without a None check. With the new access boundary in connection_manager(), unauthorized calls to /initialize, /prequisite, /sql, or socket tables will still 500.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/tools/erd/__init__.py` at line 560, The call site guarding only
panel rendering is insufficient; update the backend path that dereferences
connections so unauthorized requests don't 500 by adding a None check after
connection_manager(sid) inside _get_connection (and any callers like the
/initialize, /prerequisite, /sql handlers and socket handlers that use
_get_connection). Specifically, detect when connection_manager(sid) returns
None, and return/raise an appropriate HTTP error (e.g., 403 Forbidden or abort
with a JSON error) instead of continuing to dereference the object; ensure
helpers like get_accessible_server remain unchanged and that _get_connection
consistently enforces the access boundary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the current implementation of _get_connection and related handlers in erd/__init__.py
rg -n "connection_manager|_get_connection|None" web/pgadmin/tools/erd/__init__.py -A3 -B3

Length of output: 3255


@asheshv, the _get_connection function still dereferences manager without a None guard (Line 639–641). If connection_manager(sid) returns None for an unauthorized server, manager.connection(...) will raise an AttributeError and produce a 500 for /initialize, /prequisite, /sql, and the socket tables handler.

The close handler already does this correctly (Line 808), so the same pattern is needed inside _get_connection:

def _get_connection(sid, did, trans_id, db_name=None):
    manager = get_driver(PG_DEFAULT_DRIVER).connection_manager(sid)
    if manager is None:
        raise ConnectionLost(sid, None, trans_id)
    try:
        conn = manager.connection(
            conn_id=trans_id,
            auto_reconnect=True,
            use_binary_placeholder=True,
            **({"database": db_name} if db_name is not None else {"did": did})
        )
        status, msg = conn.connect()
        if not status:
            app.logger.error(msg)
            raise ConnectionLost(sid, conn.db, trans_id)
        return conn
    except Exception as e:
        app.logger.error(e)
        raise

Since ConnectionLost is already caught and translated into a 428 response in initialize_erd, and the other callers (prequisite, sql, tables) let exceptions propagate, you may want to handle the None case more explicitly in those callers (e.g., return a 410 JSON response), but at minimum the None check prevents the AttributeError 500.


🧠 Learnings used
Learnt from: dpage
Repo: pgadmin-org/pgadmin4 PR: 9737
File: web/pgadmin/tools/sqleditor/tests/test_nlq_chat.py:118-122
Timestamp: 2026-03-16T10:39:46.167Z
Learning: For Python tests using unittest.mock.patch, patch at the source module when the imported symbol is brought in inside a function (from x import y). The established rule “patch at the use/import site, not the definition site” only applies to module-level imports. If a from x import y statement appears inside a function body, it rebinds y on every call after patch.start(), so patching the source module (e.g., 'pgadmin.llm.chat.chat_with_database') is required. This pattern is demonstrated in web/pgadmin/tools/sqleditor/tests/test_nlq_chat.py for chat_with_database.

Learnt from: RohitBhati8269
Repo: pgadmin-org/pgadmin4 PR: 9749
File: web/pgadmin/misc/bgprocess/process_executor.py:210-225
Timestamp: 2026-03-16T11:35:24.319Z
Learning: In Python 3, sys.getdefaultencoding() always returns 'utf-8' and is not locale-dependent. Do not flag or fix code for encoding issues based on sys.getdefaultencoding() returning a non-UTF-8 value. If encoding behavior is locale-specific, rely on locale.getpreferredencoding() or, in some cases, sys.getfilesystemencoding(). Use those values for locale-aware I/O decisions, not getdefaultencoding(). This guidance applies to Python files across the codebase; ensure reviewers check for true locale-specific encoding handling where relevant, and avoid noisy encodings warnings based on getdefaultencoding().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. Added if manager is None guard in _get_connection().

@asheshv asheshv force-pushed the fix/data-isolation-bugs branch from b5b16d8 to 35a46de Compare April 8, 2026 11:29
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
web/pgadmin/tools/sqleditor/__init__.py (1)

230-239: ⚠️ Potential issue | 🟠 Major

Apply shared-server overrides here before reading saved credentials.

initialize_viewdata() now accepts shared servers via get_server(sid), but it still reads server.password from the base Server row. _init_sqleditor() already resolves SharedServer overrides first; this path should do the same or shared-server reconnects can use the owner's saved credentials instead of the current user's copy.

Possible fix
     server = get_server(sid)
     if server is None:
         return make_json_response(
             status=410, success=0,
             errormsg=gettext("Could not find the required server.")
         )
+    if server.shared and server.user_id != current_user.id:
+        from pgadmin.browser.server_groups.servers import ServerModule
+        shared_server = ServerModule.get_shared_server(server, sgid)
+        if shared_server is not None:
+            server = ServerModule.get_shared_server_properties(
+                server, shared_server
+            )
     if kwargs.get('password', None) is None:
         kwargs['encpass'] = server.password
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/tools/sqleditor/__init__.py` around lines 230 - 239,
initialize_viewdata is reading credentials from the base Server row
(server.password) instead of the resolved SharedServer overrides; update the
logic so the resolved server returned by get_server(sid) (the same resolution
used by _init_sqleditor()) is used when populating kwargs['encpass']. In
practice, apply the shared-server override resolution before checking
kwargs.get('password') and assign kwargs['encpass'] from the already-resolved
server object (not the base Server row), or reuse the override-resolution
routine from _init_sqleditor() to obtain the correct server instance prior to
reading its password.
web/pgadmin/browser/server_groups/servers/databases/__init__.py (1)

553-585: ⚠️ Potential issue | 🟠 Major

Guard the server lookup before dereferencing manager.

Line 556 and Line 593 still call connection_manager(sid)/manager.connection(...) before any not-found handling. If the sid is now inaccessible and connection_manager() returns None, both endpoints still fail with a 500 before the new get_server(sid) fallback is reached. Please fail fast here the same way the other server-gated routes in this PR do.

Also applies to: 588-609

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/browser/server_groups/servers/databases/__init__.py` around lines
553 - 585, In the connect method, guard the
get_driver(...).connection_manager(sid) result before calling manager.connection
or dereferencing manager: after assigning manager =
get_driver(PG_DEFAULT_DRIVER).connection_manager(sid) check if manager is falsy
and return the same not-found/fail-fast response used elsewhere (use
get_server(sid) to produce the fallback message) instead of proceeding; likewise
apply the same guard pattern before any manager.connection(...) calls in the
surrounding database handlers (the other block around the existing
get_server(sid) usage) so you never call manager.connection or manager methods
when connection_manager(sid) returned None.
♻️ Duplicate comments (2)
web/pgadmin/tools/sqleditor/__init__.py (1)

522-527: ⚠️ Potential issue | 🟠 Major

Return the normal 410 not-found response here, not a 500.

This branch now correctly stops _init_sqleditor() when the server is inaccessible, but internal_server_error() still reports it as a server failure. The other changed sqleditor routes use status=410, so this path will still diverge in client handling.

Possible fix
     server = get_server(sid)
     if server is None:
-        return True, internal_server_error(
-            errormsg=gettext(
-                "Could not find the required server.")
-        ), '', ''
+        return True, make_json_response(
+            status=410,
+            success=0,
+            errormsg=gettext("Could not find the required server.")
+        ), '', ''
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/tools/sqleditor/__init__.py` around lines 522 - 527, The current
branch returning internal_server_error when server = get_server(sid) is None
wrongly produces a 500; change the return so it sends a 410 Not Found status
like the other sqleditor routes. Locate the server lookup around server =
get_server(sid) in __init__.py (the early return in _init_sqleditor) and replace
the internal_server_error call with the not-found response used elsewhere (or
call internal_server_error/not_found_error with status=410) so the API returns
HTTP 410 for an inaccessible/missing server.
web/pgadmin/browser/server_groups/servers/tests/test_server_data_isolation.py (1)

98-104: ⚠️ Potential issue | 🟡 Minor

Missing response validation before dereferencing.

Unlike ServerDataIsolationGetTestCase.setUp (lines 50-52), this test lacks validation before accessing response_data['node']['_id']. If server creation fails, this will raise a noisy KeyError instead of a clear assertion failure.

💚 Proposed fix to add validation
         response = self.tester.post(
             url,
             data=json.dumps(self.server),
             content_type='html/json'
         )
+        self.assertEqual(response.status_code, 200)
         response_data = json.loads(response.data.decode('utf-8'))
+        self.assertIn('node', response_data)
         self.server_id = response_data['node']['_id']
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@web/pgadmin/browser/server_groups/servers/tests/test_server_data_isolation.py`
around lines 98 - 104, The test currently dereferences
response_data['node']['_id'] without validating the response; update the test
around the tester.post call to first assert the response status and that
response_data contains the 'node' key (and that 'node' contains '_id') before
assigning self.server_id. Specifically, after calling self.tester.post(...) and
parsing response_data, add assertions similar to those in
ServerDataIsolationGetTestCase.setUp (check response.status_code/response.status
and 'node' in response_data and '_id' in response_data['node']) so failures
produce clear assertion errors instead of KeyError when assigning
self.server_id.
🧹 Nitpick comments (1)
web/pgadmin/browser/server_groups/servers/__init__.py (1)

2112-2146: Dead code: shared server handling is unreachable after only_owned=True.

Since get_server(sid, only_owned=True) only returns servers where user_id == current_user.id, the condition at line 2122 (server.shared and server.user_id != current_user.id) will always evaluate to False. The entire if block at lines 2122-2133 and the repeated check at lines 2135-2136 and 2143-2144 are unreachable.

Consider removing this dead code to avoid confusion:

♻️ Proposed simplification
         try:
             # Owner-only: clearing saved password must not modify
             # another user's server record via shared access.
             server = get_server(sid, only_owned=True)
-            shared_server = None
             if server is None:
                 return make_json_response(
                     success=0,
                     info=self.not_found_error_msg()
                 )

-            if server.shared and server.user_id != current_user.id:
-                shared_server = SharedServer.query.filter_by(
-                    name=server.name, user_id=current_user.id,
-                    servergroup_id=gid, osid=server.id).first()
-
-                if shared_server is None:
-                    return make_json_response(
-                        success=0,
-                        info=gettext("Could not find the required server.")
-                    )
-                server = ServerModule. \
-                    get_shared_server_properties(server, shared_server)
-
-            if server.shared and server.user_id != current_user.id:
-                setattr(shared_server, 'password', None)
-            else:
-                setattr(server, 'password', None)
+            setattr(server, 'password', None)

             # If password was saved then clear the flag also
             # 0 is False in SQLite db
             if server.save_password:
-                if server.shared and server.user_id != current_user.id:
-                    setattr(shared_server, 'save_password', 0)
-                else:
-                    setattr(server, 'save_password', 0)
+                setattr(server, 'save_password', 0)
             db.session.commit()
🤖 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 2112 -
2146, The code path handling shared servers is unreachable because
get_server(sid, only_owned=True) guarantees server.user_id == current_user.id;
remove the dead shared-server branches and variables (shared_server, the if
blocks checking server.shared and server.user_id != current_user.id, and the
calls to SharedServer and ServerModule.get_shared_server_properties) and
simplify to operate directly on server (set password to None and clear
save_password flag when server.save_password is truthy, using 0 for SQLite)
while keeping the existing not-found response and behavior of get_server and
ServerModule references only where still needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@web/pgadmin/browser/server_groups/servers/utils.py`:
- Around line 658-677: The loop currently emits disconnect-psql for every socket
id stored under current_app.config['sid_soid_mapping'][str(server.id)], which
will disconnect other users on shared servers; change the logic to only target
sockets for the current owning user (or skip shared servers). Concretely, update
the code around get_user_server_query(), the server loop, and the
sid_soid_mapping lookup so you either (A) skip servers that are shared (detect
via the server ownership attribute on the Server model used in
get_user_server_query()) and only emit for non-shared servers, or (B) make the
mapping user-scoped by keying into current_app.config['sid_soid_mapping'] with a
composite key including the current user's id (e.g. server id + user id) and
only emit to those socket ids; ensure the condition that looks up
sid_soid_mapping uses the new per-user key (and adjust sio.emit calls
accordingly) before calling manager.release().

In `@web/pgadmin/browser/server_groups/tests/test_sg_data_isolation.py`:
- Around line 36-50: Initialize self.sg_id = None at the start of setUp() so
failures during the POST/JSON parse don't leave it undefined, and update
tearDown() to only attempt cleanup when self.sg_id is not None; specifically, in
the test class containing setUp and tearDown referenced above, add self.sg_id =
None as the first statement in setUp(), and guard any cleanup logic that uses
self.sg_id (e.g., deleting the server group) with a conditional check to skip
cleanup if it's None; apply the same initialization/guard pattern to the other
similar setup block noted (the one around lines 68-73).

In `@web/pgadmin/misc/workspaces/__init__.py`:
- Around line 230-232: Don't early-return when get_server(sid) yields None; that
skips best-effort cleanup for adhoc rows. Remove the immediate "return" after
server = get_server(sid) and ensure delete_adhoc_servers(sid) (and any manager
cleanup logic that follows) is executed regardless of whether server is None;
only guard usages that dereference server (e.g., calling methods/attributes on
server) with an if server: check so the cleanup still runs for
deleted/reassigned adhoc rows.

In `@web/pgadmin/tools/schema_diff/__init__.py`:
- Around line 521-526: The code currently treats get_schemas() returning None as
both "server not found" and generic failure, losing error granularity; update
get_schemas() (and its callers used in compare_database and the other two spots)
to return/raise distinct failure signals (e.g., raise specific exceptions like
ServerNotFoundError or PermissionError, or return a (result, error) tuple or a
distinct sentinel such as False for auth/server-missing and None only for
genuine empty results), then change the compare_database handler (and the other
occurrences around schema_result handling) to detect those distinct signals and
emit socketio events with accurate messages (use request.sid and
SOCKETIO_NAMESPACE as before, but emit different events/messages for
auth/server-missing vs runtime failures) so permission misses are not rewritten
as "Could not find the required server."
- Around line 34-35: The module-defined route function get_server(sid, did) is
shadowing the imported helper from pgadmin.utils.server_access and breaking
calls that expect the single-argument server-access helper; fix by aliasing the
import (e.g. import get_server as get_accessible_server) and update all internal
call sites that currently call get_server(sid) — including inside the route
itself, connect_server(), connect_database(), databases(),
check_version_compatibility(), and get_schemas() — to call
get_accessible_server(sid) so the correct helper is used while leaving the route
function name unchanged.

---

Outside diff comments:
In `@web/pgadmin/browser/server_groups/servers/databases/__init__.py`:
- Around line 553-585: In the connect method, guard the
get_driver(...).connection_manager(sid) result before calling manager.connection
or dereferencing manager: after assigning manager =
get_driver(PG_DEFAULT_DRIVER).connection_manager(sid) check if manager is falsy
and return the same not-found/fail-fast response used elsewhere (use
get_server(sid) to produce the fallback message) instead of proceeding; likewise
apply the same guard pattern before any manager.connection(...) calls in the
surrounding database handlers (the other block around the existing
get_server(sid) usage) so you never call manager.connection or manager methods
when connection_manager(sid) returned None.

In `@web/pgadmin/tools/sqleditor/__init__.py`:
- Around line 230-239: initialize_viewdata is reading credentials from the base
Server row (server.password) instead of the resolved SharedServer overrides;
update the logic so the resolved server returned by get_server(sid) (the same
resolution used by _init_sqleditor()) is used when populating kwargs['encpass'].
In practice, apply the shared-server override resolution before checking
kwargs.get('password') and assign kwargs['encpass'] from the already-resolved
server object (not the base Server row), or reuse the override-resolution
routine from _init_sqleditor() to obtain the correct server instance prior to
reading its password.

---

Duplicate comments:
In
`@web/pgadmin/browser/server_groups/servers/tests/test_server_data_isolation.py`:
- Around line 98-104: The test currently dereferences
response_data['node']['_id'] without validating the response; update the test
around the tester.post call to first assert the response status and that
response_data contains the 'node' key (and that 'node' contains '_id') before
assigning self.server_id. Specifically, after calling self.tester.post(...) and
parsing response_data, add assertions similar to those in
ServerDataIsolationGetTestCase.setUp (check response.status_code/response.status
and 'node' in response_data and '_id' in response_data['node']) so failures
produce clear assertion errors instead of KeyError when assigning
self.server_id.

In `@web/pgadmin/tools/sqleditor/__init__.py`:
- Around line 522-527: The current branch returning internal_server_error when
server = get_server(sid) is None wrongly produces a 500; change the return so it
sends a 410 Not Found status like the other sqleditor routes. Locate the server
lookup around server = get_server(sid) in __init__.py (the early return in
_init_sqleditor) and replace the internal_server_error call with the not-found
response used elsewhere (or call internal_server_error/not_found_error with
status=410) so the API returns HTTP 410 for an inaccessible/missing server.

---

Nitpick comments:
In `@web/pgadmin/browser/server_groups/servers/__init__.py`:
- Around line 2112-2146: The code path handling shared servers is unreachable
because get_server(sid, only_owned=True) guarantees server.user_id ==
current_user.id; remove the dead shared-server branches and variables
(shared_server, the if blocks checking server.shared and server.user_id !=
current_user.id, and the calls to SharedServer and
ServerModule.get_shared_server_properties) and simplify to operate directly on
server (set password to None and clear save_password flag when
server.save_password is truthy, using 0 for SQLite) while keeping the existing
not-found response and behavior of get_server and ServerModule references only
where still needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c5197c81-eec3-4cbe-a8c5-aaf3a5ca0583

📥 Commits

Reviewing files that changed from the base of the PR and between b5b16d8 and 35a46de.

📒 Files selected for processing (18)
  • web/migrations/versions/add_user_id_to_debugger_func_args_.py
  • web/pgadmin/browser/server_groups/__init__.py
  • web/pgadmin/browser/server_groups/servers/__init__.py
  • web/pgadmin/browser/server_groups/servers/databases/__init__.py
  • web/pgadmin/browser/server_groups/servers/databases/schemas/views/__init__.py
  • web/pgadmin/browser/server_groups/servers/tests/test_server_data_isolation.py
  • web/pgadmin/browser/server_groups/servers/utils.py
  • web/pgadmin/browser/server_groups/tests/test_sg_data_isolation.py
  • web/pgadmin/misc/workspaces/__init__.py
  • web/pgadmin/tools/debugger/__init__.py
  • web/pgadmin/tools/erd/__init__.py
  • web/pgadmin/tools/import_export/__init__.py
  • web/pgadmin/tools/psql/__init__.py
  • web/pgadmin/tools/schema_diff/__init__.py
  • web/pgadmin/tools/sqleditor/__init__.py
  • web/pgadmin/utils/__init__.py
  • web/pgadmin/utils/driver/psycopg3/__init__.py
  • web/pgadmin/utils/server_access.py
✅ Files skipped from review due to trivial changes (3)
  • web/pgadmin/tools/debugger/init.py
  • web/pgadmin/tools/psql/init.py
  • web/pgadmin/tools/erd/init.py
🚧 Files skipped from review as they are similar to previous changes (6)
  • web/pgadmin/utils/driver/psycopg3/init.py
  • web/pgadmin/browser/server_groups/servers/databases/schemas/views/init.py
  • web/pgadmin/browser/server_groups/init.py
  • web/migrations/versions/add_user_id_to_debugger_func_args_.py
  • web/pgadmin/utils/init.py
  • web/pgadmin/tools/import_export/init.py

@asheshv asheshv force-pushed the fix/data-isolation-bugs branch 2 times, most recently from 022ff77 to 3e0dc28 Compare April 8, 2026 12:15
asheshv added 2 commits April 8, 2026 17:50
In server mode, multiple users share one pgAdmin instance. Several
code paths loaded objects belonging to all users without filtering,
allowing cross-user information disclosure.

Adds centralized get_server() helper with only_owned parameter for
write operations, scopes all server/group queries, adds access check
in connection_manager(), and includes Alembic migration for
DebuggerFunctionArguments user_id column.
Mixin providing Model.for_user(**kwargs) as the standard scoped query
entry point. Applied to 11 user-scoped models. Converted all Process
query callsites. Updated BatchProcess test mocks.
@asheshv asheshv force-pushed the fix/data-isolation-bugs branch from 3e0dc28 to f097f70 Compare April 8, 2026 12:26
Three test classes verifying server/group isolation and shared server
access. Uses create_user_wise_test_client pattern, SERVER_MODE only.
@asheshv asheshv force-pushed the fix/data-isolation-bugs branch from f097f70 to b44713f Compare April 8, 2026 12:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant