Skip to content

feat: make db a real non-superuser app_user client (RLS enforced), bump pgpm/postgres - #7

Merged
pyramation merged 1 commit into
mainfrom
feat/app-user-db-client
Sep 16, 2026
Merged

pyramation merged 1 commit into
mainfrom
feat/app-user-db-client

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

Summary

get_connections() previously returned db = pg (both superuser, README said "same as pg for now"), so RLS tests on db were testing nothing — superusers bypass RLS. This brings the Python port to parity with the TS pgsql-test getConnections flow:

admin.create_user_role(app_user, app_password, [anonymous, authenticated, administrator])  # NOLOGIN roles created if missing, membership granted
admin.create_database(test_db); install extensions
admin.grant_connect(app_user, test_db)
pg = manager.get_client(test_config)                       # superuser
seed adapters run on pg; pg.commit()                       # seeds were never committed before (only "visible" because db was pg)
db = manager.get_client({**test_config, user: app_user}, default_role="anonymous")
db.set_context({"role": "anonymous"})

PgTestClient context now mirrors the TS client: role -> SET LOCAL ROLE "<role>", everything else -> set_config(key, value, true), re-applied before every query() so it survives the whole before_each()/after_each() window. Added get_context(), clear_context() (nulls GUCs, restores default role). New ConnectionOptions.connection / .roles (AppConnection, RoleMapping) to override the defaults.

Behavioral change for consumers: tables created via pg/seeds are not visible to db until granted (as in production and in the TS package). Repo tests updated accordingly (setup/asserts on pg, GRANT ... TO anonymous in fixtures); pgpm fixture migration now grants USAGE on test_app to the app roles.

tests/test_rls.py proves it end-to-end against Postgres: pg is superuser and sees all rows; db session_user is app_user, current_user is anonymous, gets InsufficientPrivilege without grants, authenticated + jwt.claims.user_id is filtered by SELECT/INSERT policies, and clear_context() restores anonymous.

Also: CI PGPM_VERSION 2.7.9 -> 5.30.6, Node 20 -> 22, postgres-plus:17 -> :18 (latest @pgpm/faker uses uuidv7(), PG18-only), package version 0.3.0.

Link to Devin session: https://app.devin.ai/sessions/b0917661c1224564b5c5df42de52395d
Open in Devin Desktop: https://app.devin.ai/desktop/session/b0917661c1224564b5c5df42de52395d?variant=devin
Requested by: @pyramation

@devin-ai-integration

Copy link
Copy Markdown

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@tenki-reviewer

tenki-reviewer Bot commented Sep 16, 2026

Copy link
Copy Markdown

Review complete. 🔴 1 critical · 🟠 6 high

💬 Inline comments (2)

📍 Findings outside the diff (5) — 🔴 1 critical · 🟠 4 high — defects on lines GitHub can't attach comments to

🔴 Critical — set_context called before transaction started · src/pgsql_test/connect.py · unchanged line

At line 481 in connect.py, db.set_context({'role': default_role}) is called immediately after the db client is created, but before any transaction is started via before_each(). The new set_context() method (line 351 in client.py) only updates the context dict and does not apply context immediately (the old explicit call to _apply_context() at line 369 was removed). This means the role is stored but not applied until the first query, which happens after the user starts a transaction with before_each(). However, for RLS policies to work correctly, the context must be applied WITHIN the transaction.


🟠 High — SQL syntax corrected with GRANT statements added · tests/test_example.py · unchanged line

The CREATE TABLE statements in test_example.py (line 651-653) and test_pets_rollback.py (line 665-667) were modified to add GRANT ALL ON table, sequence TO anonymous; statements. This is required because the db client now connects as app_user (non-superuser) instead of as the superuser. The syntax change itself (adding ; after the closing paren) is correct.


🟠 High — Context application moved from before_each to per-query · src/pgsql_test/client.py · unchanged line

The _apply_context() call was removed from before_each() (previously lines 407-408) and moved to be called before each query() execution (line 342 in client.py). Since transactions are opened by before_each() and context is now applied per-query, the context must persist within the transaction. The docstring claims context is "re-applied before every query", but this depends on correct transaction handling.


🟠 High — default_role parameter threading mismatch · src/pgsql_test/manager.py · unchanged line

The default_role parameter is added to both PgTestClient.__init__() (line 319) and PgTestConnector.get_client() (line 494). However, the new call at line 480 in connect.py passes default_role=default_role, but if other code paths call get_client() without this parameter, they will instantiate PgTestClient with default_role=None, which may cause unexpected behavior in clear_context() (line 388) where it unconditionally sets self._context['role'] = self._default_role (src/pgsql_test/manager.py, line 509).


🟠 High — clear_context may not fully reset non-role GUCs · src/pgsql_test/client.py · unchanged line

The clear_context() method (lines 385-388) sets all context dict keys to None, then overrides 'role' with _default_role. When _apply_context() is called, non-'role' keys will execute set_config(key, None, true). It is unclear whether PostgreSQL's set_config() with a NULL value fully unsets the GUC or leaves it in a partially-set state (src/pgsql_test/client.py, line 399).


This PR changes how the pgsql-test client authenticates and applies database context, shifting test connections from a superuser pg role to a dedicated non-superuser app_user with SET LOCAL ROLE anonymous, and moves context application from before_each() to per-query via a new set_context()/_apply_context() flow. It also bumps CI toolchain versions, updates packaging metadata, and adds GRANT statements and RLS/rollback tests.

Files Change
src/pgsql_test/connect.py, client.py, manager.py, types.py Introduced per-query context/RLS application, set_context(), clear_context(), and default_role threading across client creation.
tests/test_rls.py, test_pets_rollback.py, test_example.py, test_basic.py, test_pgpm_integration.py Added RLS/rollback coverage and GRANT statements to support non-superuser app_user connects.
.github/workflows/*.yml, README.md, WORKSPACE_SETUP.md, pyproject.toml, __init__.py Updated CI toolchain versions, docs, and release metadata.

Note: the verified.json adjudication output was not produced, so findings were merged from raw scanner candidates (unverified).

Reviewed commit: 67d42f8

@tenki-reviewer tenki-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

PR #7 switches test clients from superuser to a non-superuser app_user/anonymous role and rewrites context/RLS handling, moving context application from before_each() to per-query.

Key findings

  • 🔴 set_context called before transaction startedconnect.py
  • 🟠 SQL syntax corrected with GRANT statements addedtest_example.py
  • 🟠 Context application moved from before_each to per-queryclient.py
  • 🟠 default_role parameter threading mismatchmanager.py
  • 🟠 clear_context fails to reset RLS GUCsclient.py:230
  • 🟠 clear_context may not fully reset non-role GUCsclient.py
  • 🟠 conn.db is now non-superuser app clientconnect.py:206

Comment thread src/pgsql_test/client.py
else:
cur.execute(pgsql.SQL("SET LOCAL ROLE {}").format(pgsql.Identifier(value)))
else:
cur.execute("SELECT set_config(%s, %s, true)", (key, value))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 bug · high

clear_context fails to reset RLS GUCs

In _apply_context (src/pgsql_test/client.py:230), a cleared context key with value None runs SELECT set_config(%s,%s,true) passing SQL NULL as the new value; PostgreSQL treats a NULL new_value as no-op (it never assigns NULL to a GUC), so the prior transaction-local setting from an earlier query in the same transaction stays active. As a result clear_context() (client.py:216) nulls the in-memory dict but does not actually clear the live RLS variables, so policies keyed on e.g. jwt.claims.user_id keep seeing the stale value across a context reset.

The same stale-state risk applies to any caller passing None in set_context() to mean reset (per its docstring).

📋 Prompt for AI Agents

In src/pgsql_test/client.py at line 230 in _apply_context(), handle a None value for non-role keys explicitly instead of passing it to set_config, since PostgreSQL set_config() with a NULL new_value is a no-op. Change the else branch so that when value is None you run cur.execute('SELECT set_config(%s, %s, true)', (key, '')) (or a RESET-style clear) to actually remove the transaction-local variable, and keep the non-None path passing the string normally. This ensures clear_context() genuinely clears RLS settings as its docstring promises.

Comment thread src/pgsql_test/connect.py
Comment on lines +206 to +209
# The app-level client: a real non-superuser connection so RLS policies apply
db_config: PgConfig = {**test_config, "user": app_user, "password": app_password}
db = manager.get_client(db_config, default_role=default_role)
db.set_context({"role": default_role})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 high

conn.db is now non-superuser app client

get_connections() previously returned db as the same superuser connection as pg (the removed comment read "For now, db is the same as pg (both superuser)"); it now returns a distinct app_user client with SET LOCAL ROLE anonymous applied (connect.py:207-209). Existing callers that used conn.db for DDL (CREATE TABLE, GRANT), admin operations, or superuser-bypassing reads will now receive InsufficientPrivilege errors and see role-filtered rows; the db field's connection-level contract changed from superuser to app-scoped.

📋 Prompt for AI Agents

In src/pgsql_test/connect.py lines 206-209, the get_connections() return value db changed from the superuser pg connection to a dedicated non-superuser app_user client. Update the module docstring, ConnectionResult docstring (connect.py lines 54-65), and README to clearly state that db is a non-superuser, RLS-subject client and that DDL/admin operations must be performed via conn.pg; add a CHANGELOG entry noting the breaking behavior change in the 0.3.0 release.

@pyramation
pyramation merged commit 608c70f into main Sep 16, 2026
7 checks passed
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