Skip to content

fix(server): enforce read-only/preview modes on REST API (DRC-3828)#1459

Open
kentwelcome wants to merge 3 commits into
mainfrom
fix/drc-3828-enforce-server-mode-rest-api
Open

fix(server): enforce read-only/preview modes on REST API (DRC-3828)#1459
kentwelcome wants to merge 3 commits into
mainfrom
fix/drc-3828-enforce-server-mode-rest-api

Conversation

@kentwelcome

@kentwelcome kentwelcome commented Jul 6, 2026

Copy link
Copy Markdown
Member

PR checklist

  • Ensure you have added or ran the appropriate tests for your PR.
  • DCO signed

What type of PR is this?

fix / security

What 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, and save/rename/export state — despite read_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_mode HTTP middleware in recce/server.py:

  • Preview (metadata-only) blocks query execution: POST /api/runs of a warehouse-query run type, and POST /api/checks/{id}/run.
  • Read-only additionally blocks checklist mutations (POST/PATCH/DELETE under /api/checks*) and state writes (/api/save, /api/save-as, /api/rename, /api/export).
  • Metadata runs (lineage_diff, schema_diff) and all GET reads remain allowed, so preview/read-only UIs keep working.

The blocked run-type set mirrors the blocked-tool set in mcp_server.py so REST and MCP agree. Denials return 403 with a human-readable reason.

Which issue(s) this PR fixes:

Fixes DRC-3828 (external security report).

Special notes for your reviewer:

  • The gate is one central function _mode_block_reason(...) (pure, unit-tested) plus a thin middleware. Only POST /api/runs needs 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.py covers the full policy matrix plus one TestClient run that verifies the body-replay doesn't break the real handler.
  • Deliberately left allowed: GET /api/runs/{id}/export (exporting an existing run's already-visible results) and POST /api/runs/{id}/cancel. Say if you'd rather gate those too.
  • Not addressed here (tracked as follow-ups in DRC-3828): optional API auth, request logging, rate limiting.

Does this PR introduce a user-facing change?:

Read-only and preview server modes are now enforced server-side on the REST API. Query execution and state/checklist mutations that were previously blocked only in the UI are now rejected with HTTP 403 in the corresponding modes.

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>
Copilot AI review requested due to automatic review settings July 6, 2026 13:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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).

Comment thread recce/server.py
Comment on lines +512 to +516
# 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"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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).

Comment on lines +35 to +50
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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Files with missing lines Coverage Δ
recce/server.py 73.21% <100.00%> (+1.19%) ⬆️
tests/test_cli.py 100.00% <100.00%> (ø)
tests/test_server_mode_guard.py 100.00% <100.00%> (ø)

... and 4 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@kentwelcome kentwelcome requested a review from wcchang1115 July 7, 2026 00:42
…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 wcchang1115 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

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.

3 participants