feat: Add readonly access mode for database-level read-only enforcement#148
Open
yaryrslv wants to merge 1 commit intocrystaldba:mainfrom
Open
feat: Add readonly access mode for database-level read-only enforcement#148yaryrslv wants to merge 1 commit intocrystaldba:mainfrom
yaryrslv wants to merge 1 commit intocrystaldba:mainfrom
Conversation
Add a third access mode (--access-mode=readonly) that sits between `unrestricted` and `restricted`, solving the problem of pglast rejecting legitimate complex read-only queries. Background: The existing `restricted` mode uses pglast to parse SQL into an AST and validate every statement, function call, and node type against whitelists. This catches write operations at the application layer, but also rejects valid read-only queries that pglast cannot parse: nested CTEs, PERCENTILE_CONT with WITHIN GROUP, complex window functions, and queries using extensions or newer PostgreSQL syntax. Users running analytical workloads hit pglast rejections on safe queries and are forced to switch to `unrestricted` mode, losing all protection. How it works: ReadOnlySqlDriver wraps a base SqlDriver using the decorator pattern and overrides execute_query() to: 1. Always set force_readonly=True (ignoring the caller's value) 2. Prepend /* crystaldba */ comment for query identification in logs 3. Enforce a configurable timeout (default 30s) via asyncio.timeout() The underlying SqlDriver.execute_query() with force_readonly=True executes queries inside a read-only transaction: BEGIN TRANSACTION READ ONLY -- PostgreSQL enforces no writes /* crystaldba */ <query> -- user's query runs here ROLLBACK -- transaction closed PostgreSQL itself rejects any INSERT, UPDATE, DELETE, DROP, CREATE, or other write operation inside a READ ONLY transaction — this is database-engine-level enforcement, not application-layer parsing. Protection comparison across modes: Mode SQL validation DB enforcement Complex queries ────────────────────────────────────────────────────────────────── unrestricted none none all allowed readonly none READ ONLY tx all allowed restricted pglast AST check READ ONLY tx limited by pglast Security trade-off: Readonly mode does not validate SQL syntax. A multi-statement payload like "COMMIT; DROP TABLE users" is not caught at the application layer. However, PostgreSQL's read-only transaction prevents the write from executing. This is an intentional trade-off: pglast's false rejections of valid analytical queries cause more real-world pain than the theoretical multi-statement attack vector, which is mitigated by the database transaction boundary. Changes: - src/postgres_mcp/sql/readonly_sql.py: new ReadOnlySqlDriver class (64 lines, decorator over SqlDriver, timeout + force_readonly) - src/postgres_mcp/server.py: AccessMode.READONLY enum value, get_sql_driver() branch returning ReadOnlySqlDriver(timeout=30), --access-mode=readonly CLI argument - src/postgres_mcp/sql/__init__.py: export ReadOnlySqlDriver - smithery.yaml: add "readonly" to access mode config - README.md: update "Protected SQL Execution" to document three levels - tests/unit/sql/test_readonly_sql.py: 8 unit tests (force_readonly override, no SQL validation, comment prepending, timeout, parameter forwarding, exception propagation, None result handling) - tests/unit/sql/test_readonly_enforcement.py: 3 parameterized tests verifying force_readonly behavior across all three access modes - tests/unit/test_access_mode.py: driver selection + CLI parsing tests
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Add a third access mode
--access-mode=readonlythat enforces read-onlytransactions at the PostgreSQL level without pglast SQL validation.
This fills the gap between
unrestricted(no protection) andrestricted(pglast rejects complex queries).
Problem
The
restrictedmode uses pglast to parse SQL into an AST and validatestatements against whitelists. While this catches writes at the application
layer, it also rejects legitimate read-only queries that pglast cannot
parse:
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY duration)— pglast parse errorpg_trgm,postgis, etc.) — not in the allowed function whitelistUsers running analytical workloads are forced to switch to
unrestrictedmode, losing all write protection.
Solution
Architecture: Driver selection
The server selects a driver based on
--access-modeCLI argument:flowchart TD CLI["--access-mode=?"] --> U{unrestricted} CLI --> RO{readonly} CLI --> R{restricted} U --> SD["SqlDriver\n(no wrapper)"] RO --> ROSD["ReadOnlySqlDriver\n(decorator over SqlDriver)"] R --> SSD["SafeSqlDriver\n(decorator over SqlDriver)"] SD --> EQ["execute_query()\nforce_readonly=False"] ROSD --> EQ2["execute_query()\nforce_readonly=True ← forced\n+ /* crystaldba */ prefix\n+ 30s timeout"] SSD --> VAL["_validate(query)\npglast AST parsing"] VAL -->|pass| EQ3["execute_query()\nforce_readonly=True ← forced\n+ /* crystaldba */ prefix\n+ 30s timeout"] VAL -->|fail| ERR["Reject query\n(ValueError)"] EQ --> PG[(PostgreSQL)] EQ2 --> PG EQ3 --> PG style ROSD fill:#2d6a4f,stroke:#1b4332,color:#fff style RO fill:#2d6a4f,stroke:#1b4332,color:#fffArchitecture: ReadOnlySqlDriver decorator pattern
flowchart TD CALLER["MCP Tool calls execute_query()"] --> ROSD subgraph ROSD["ReadOnlySqlDriver"] A["Ignore caller's force_readonly value"] --> B["Prepend /* crystaldba */ comment"] B --> C["Wrap in asyncio.timeout(30s)"] C --> D["Call self.sql_driver.execute_query(\n query='/* crystaldba */ ...',\n params=params,\n force_readonly=True\n)"] end D --> EXEC subgraph EXEC["SqlDriver._execute_with_connection"] E["BEGIN TRANSACTION READ ONLY"] --> F["Execute user's query"] F --> G{Success?} G -->|Yes| H["ROLLBACK"] G -->|No| I["connection.rollback()"] H --> J["Return results"] I --> K["Raise exception"] end J --> RESULT["MCP Tool Response\n(query results)"] K --> ERROR["MCP Tool Response\n(error message)"] style ROSD fill:#2d6a4f,stroke:#1b4332,color:#fff style EXEC fill:#1b4332,stroke:#081c15,color:#fffArchitecture: Query flow in readonly mode (sequence)
sequenceDiagram participant Tool as MCP Tool participant RO as ReadOnlySqlDriver participant SD as SqlDriver participant PG as PostgreSQL Tool->>RO: execute_query(sql, params, force_readonly=False) Note over RO: Ignores force_readonly=False Note over RO: Prepends /* crystaldba */ Note over RO: Starts asyncio.timeout(30s) RO->>SD: execute_query("/* crystaldba */ " + sql, params, force_readonly=True) SD->>PG: BEGIN TRANSACTION READ ONLY PG-->>SD: OK SD->>PG: /* crystaldba */ SELECT ... alt Query succeeds PG-->>SD: rows SD->>PG: ROLLBACK SD-->>RO: List[RowResult] RO-->>Tool: List[RowResult] else Write attempt (INSERT, UPDATE, DROP...) PG-->>SD: ERROR: cannot execute INSERT in a read-only transaction SD->>PG: ROLLBACK SD-->>RO: raises Exception RO-->>Tool: raises Exception else Timeout (>30s) Note over RO: asyncio.TimeoutError RO-->>Tool: ValueError("Query execution timed out after 30 seconds") endArchitecture: Protection layers comparison
graph LR subgraph unrestricted["unrestricted mode"] U1["SQL query"] --> U2["SqlDriver"] --> U3[(PostgreSQL)] end subgraph readonly["readonly mode"] R1["SQL query"] --> R2["ReadOnlySqlDriver\n(timeout + prefix)"] --> R3["SqlDriver\nforce_readonly=True"] --> R4[("PostgreSQL\nREAD ONLY tx")] end subgraph restricted["restricted mode"] S1["SQL query"] --> S2["SafeSqlDriver\npglast validation"] --> S3["SqlDriver\nforce_readonly=True"] --> S4[("PostgreSQL\nREAD ONLY tx")] end style unrestricted fill:#d4380d,stroke:#a8071a,color:#fff style readonly fill:#2d6a4f,stroke:#1b4332,color:#fff style restricted fill:#0050b3,stroke:#003a8c,color:#fffMode comparison
unrestrictedreadonlyrestrictedREAD ONLYtransactionREAD ONLYtransaction/* crystaldba *//* crystaldba */Security trade-off
flowchart LR subgraph restricted_mode["restricted mode (dual-layer)"] direction TB L1["Layer 1: pglast\nAST validation"] --> L2["Layer 2: PostgreSQL\nREAD ONLY transaction"] end subgraph readonly_mode["readonly mode (single-layer)"] direction TB L3["No SQL validation"] --> L4["PostgreSQL\nREAD ONLY transaction"] end ATK["Multi-statement:\nCOMMIT; DROP TABLE users"] ATK -->|blocked by pglast| restricted_mode ATK -->|not parsed, but\nDROP fails in\nREAD ONLY tx| readonly_mode style restricted_mode fill:#0050b3,stroke:#003a8c,color:#fff style readonly_mode fill:#2d6a4f,stroke:#1b4332,color:#fffReadonly mode does not validate SQL syntax. Multi-statement payloads
like
COMMIT; DROP TABLE usersare not caught at the application layer.PostgreSQL's read-only transaction prevents the write from executing,
but this is single-layer protection vs. the dual-layer approach of
restrictedmode.This trade-off is intentional: pglast's false rejections of valid
analytical queries cause more real-world friction than the multi-statement
attack vector, which is mitigated by the database transaction boundary.
Usage
Implementation details
ReadOnlySqlDriverclass (src/postgres_mcp/sql/readonly_sql.py)SqlDriverReadOnlySqlDriver(sql_driver: SqlDriver, timeout: float | None = None)execute_query()always passesforce_readonly=Trueto the underlying driver, regardless of the caller's valueasyncio.timeout(self.timeout), raisesValueErroron expiry/* crystaldba */comment for identification inpg_stat_activityand logsSafeSqlDriver, does not call_validate()/ pglastget_sql_driver()dispatch (src/postgres_mcp/server.py)Transaction enforcement (
src/postgres_mcp/sql/sql_driver.py)When
force_readonly=True,_execute_with_connection()wraps every query:PostgreSQL rejects any
INSERT,UPDATE,DELETE,DROP,CREATE,TRUNCATE, or other write operation inside aREAD ONLYtransaction with:Files changed
src/postgres_mcp/sql/readonly_sql.pyReadOnlySqlDriverclass (decorator, timeout, force_readonly)src/postgres_mcp/server.pyAccessMode.READONLYenum value,get_sql_driver()branch, CLI--access-mode=readonlysrc/postgres_mcp/sql/__init__.pyReadOnlySqlDriversmithery.yamlreadonlyto access mode configREADME.mdtests/unit/sql/test_readonly_sql.pytests/unit/sql/test_readonly_enforcement.pytests/unit/test_access_mode.pyTest coverage
test_readonly_sql.py(8 tests): force_readonly override, no SQL validation,/* crystaldba */prefix, timeout → ValueError, parameter forwarding,exception propagation, None result handling
test_readonly_enforcement.py(3 parameterized tests): verifies force_readonlybehavior per access mode (unrestricted respects caller, readonly/restricted force True)
test_access_mode.py: get_sql_driver() returns correct type per mode,timeout=30 for restricted/readonly, CLI
--access-modeparsing