fix(server): enforce read-only/preview modes on REST API (DRC-3828)#1459
fix(server): enforce read-only/preview modes on REST API (DRC-3828)#1459kentwelcome wants to merge 3 commits into
Conversation
Read-only and preview modes were enforced only in the frontend; the REST
API had no server-side guard, so any caller could run arbitrary SQL and
mutate state by hitting the endpoints directly, bypassing the intended
sharing boundary. The MCP server already gates these operations by mode.
Add an enforce_server_mode middleware in server.py mirroring the MCP
blocked-tool set. Preview blocks query execution (POST /api/runs of a
query type and POST /api/checks/{id}/run); read-only additionally blocks
checklist mutations and save/save-as/rename/export. Metadata runs
(lineage_diff, schema_diff) and GET reads remain allowed.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Kent Huang <kent@infuseai.io>
There was a problem hiding this comment.
Pull request overview
This PR adds server-side enforcement for Recce “preview” and “read-only” modes on the REST API, aligning backend behavior with the frontend UX restrictions and the MCP server’s existing mode gating.
Changes:
- Introduces a centralized policy function (
_mode_block_reason) and an HTTP middleware (enforce_server_mode) to deny disallowed REST actions with HTTP 403. - Blocks query-executing run types in preview/read-only, and blocks state/checklist mutations in read-only.
- Adds unit tests to cover the policy matrix plus a TestClient-based check verifying request-body replay behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
recce/server.py |
Adds server-mode enforcement middleware and the mode policy logic used to gate REST endpoints. |
tests/test_server_mode_guard.py |
Adds tests for mode gating policy and middleware behavior (including request body replay). |
| # Query execution — blocked in both preview and read-only. | ||
| if method == "POST" and path == "/api/runs" and run_type in _QUERY_RUN_TYPES: | ||
| return f"run type '{run_type}' executes a query" | ||
| if method == "POST" and path.startswith("/api/checks/") and path.endswith("/run"): | ||
| return "running a check executes a query" |
There was a problem hiding this comment.
Good catch — confirmed. _execute_export_query (run_api.py:374) re-executes the run's sql_template against the warehouse, so this endpoint is query execution, not a plain read. Fixed in 91a161c: GET /api/runs/{id}/export is now blocked in both preview and read-only via _mode_block_reason (new GET rule alongside the POST /api/runs and check-run gates).
| def test_read_only_blocks_queries_and_writes(): | ||
| assert _blocked("POST", "/api/runs", "query", read_only=True) | ||
| assert _blocked("POST", "/api/checks/abc/run", read_only=True) | ||
| assert _blocked("POST", "/api/checks", read_only=True) | ||
| assert _blocked("PATCH", "/api/checks/abc", read_only=True) | ||
| assert _blocked("DELETE", "/api/checks/abc", read_only=True) | ||
| assert _blocked("POST", "/api/checks/reorder", read_only=True) | ||
| assert _blocked("POST", "/api/save", read_only=True) | ||
| assert _blocked("POST", "/api/save-as", read_only=True) | ||
| assert _blocked("POST", "/api/rename", read_only=True) | ||
| assert _blocked("POST", "/api/export", read_only=True) | ||
| # Metadata runs and reads remain allowed even in read-only. | ||
| assert not _blocked("POST", "/api/runs", "lineage_diff", read_only=True) | ||
| assert not _blocked("GET", "/api/runs", read_only=True) | ||
| assert not _blocked("GET", "/api/checks", read_only=True) | ||
| assert not _blocked("POST", "/api/runs/search", read_only=True) |
There was a problem hiding this comment.
Added in 91a161c: the policy matrix now asserts GET /api/runs/{id}/export is blocked in both preview (test_preview_blocks_query_execution_only) and read-only (test_read_only_blocks_queries_and_writes).
…ak (DRC-3828)
Address PR review: GET /api/runs/{id}/export re-executes the run's SQL via
_execute_export_query, so it is query execution — block it in preview and
read-only, matching the 'no run query' guarantee. Add policy-matrix
assertions for the export endpoint.
Also fix a pre-existing test leak the middleware exposed: TestCommandServer
in test_cli.py assigns to the shared module app.state (cli.py:1427),
sometimes a MagicMock, and never restored it. A leaked MagicMock flag made
flag.get('read_only') truthy, tripping the guard and 403-ing later tests
(test_saveas_and_rename). Add an autouse fixture that restores app.state.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Kent Huang <kent@infuseai.io>
Codecov Report✅ All modified and coverable lines are covered by tests.
... and 4 files with indirect coverage changes 🚀 New features to boost your workflow:
|
…e (DRC-3828) Patch coverage was 96.9%: the malformed-body except branch was untested and the _replay closure was never exercised. Starlette 1.3.1's BaseHTTPMiddleware buffers the request body, so reading it in the middleware does not starve the downstream handler — the _replay closure was dead code. Remove it and add a malformed-body test, bringing patch coverage to 100%. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Kent Huang <kent@infuseai.io>
wcchang1115
left a comment
There was a problem hiding this comment.
Code Review: PR #1459
SHA 8f96a57c · Verdict GO
Server-side enforcement of read-only/preview on the REST API is correct and well-tested — closes the "frontend-only guard, bypassable via direct API calls" hole.
1. Fix two stale claims in the PR description (non-blocking).
(a) Special Notes says GET /api/runs/{id}/export is "deliberately left allowed," but the code blocks it (server.py:519) — correctly, since _execute_export_query re-runs the query against the warehouse. (b) "REST and MCP agree" holds for query execution only; mcp_server.py also blocks checklist ops (create_check/list_checks) in preview, while REST allows them in preview. Just tighten the wording.
2. Follow-up (non-blocking, out of scope). The frontend run-result export button (useCSVExport.ts) isn't mode-aware, so in read-only/preview it stays enabled and now yields a silent broken download on the 403. Pre-existing (already 500'd on Share instances, which have no warehouse) — not introduced here, worth a ticket.
Bypass surface checked, clean. Every query-executing run type is in _QUERY_RUN_TYPES (exact RunType() match, no case/whitespace evasion; simple is unregistered → 400); the WebSocket path can't submit runs or mutate state.
PR checklist
What type of PR is this?
fix/ securityWhat this PR does / why we need it:
Read-only and preview server modes were enforced only in the frontend. The REST API had no server-side guard, so any unauthenticated caller could bypass the sharing boundary by hitting the endpoints directly — run arbitrary SQL via
POST /api/runs, create/modify/delete checks, andsave/rename/exportstate — despiteread_only: true. The MCP server already gates these operations by mode (mcp_server.py); this PR brings the REST API in line.Adds an
enforce_server_modeHTTP middleware inrecce/server.py:POST /api/runsof a warehouse-query run type, andPOST /api/checks/{id}/run.POST/PATCH/DELETEunder/api/checks*) and state writes (/api/save,/api/save-as,/api/rename,/api/export).lineage_diff,schema_diff) and allGETreads remain allowed, so preview/read-only UIs keep working.The blocked run-type set mirrors the blocked-tool set in
mcp_server.pyso REST and MCP agree. Denials return403with a human-readable reason.Which issue(s) this PR fixes:
Fixes DRC-3828 (external security report).
Special notes for your reviewer:
_mode_block_reason(...)(pure, unit-tested) plus a thin middleware. OnlyPOST /api/runsneeds body inspection (run type is in the body); the body is read and replayed downstream so handlers still parse it.tests/test_server_mode_guard.pycovers the full policy matrix plus oneTestClientrun that verifies the body-replay doesn't break the real handler.GET /api/runs/{id}/export(exporting an existing run's already-visible results) andPOST /api/runs/{id}/cancel. Say if you'd rather gate those too.Does this PR introduce a user-facing change?: