Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
* [Cache](get-started/cache.md)
* [Migrating](get-started/migrating.md)
* [Permissions](get-started/permissions.md)
* [Query Guard](get-started/query-guard.md)
* [AI and MCP](ai/README.md)
* [MCP Overview](ai/mcp-overview.md)
* [Install pREST MCP Adapter](ai/install-prest-mcp.md)
Expand Down
1 change: 1 addition & 0 deletions get-started/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ PostgreSQL is the native adapter today; see [Databases](../databases/README.md)
* [MCP over HTTP](mcp-over-http.md) — Model Context Protocol at `/_mcp`
* [Cache](cache.md)
* [Permissions](permissions.md)
* [Query Guard](query-guard.md) — reject expensive queries by execution plan
* [Migrating](migrating.md)
* [CORS Support](cors-support.md)

Expand Down
12 changes: 12 additions & 0 deletions get-started/configuring-prest.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ The _**prestd**_ configuration is via an _environment variable_ or _toml_ file.
| `PREST_EXPOSE_SCHEMAS` | `true` | when `false`, disables schema listing. See [Expose Data](#expose-data) |
| `PREST_EXPOSE_DATABASES` | `true` | when `false`, disables database listing. See [Expose Data](#expose-data) |
| `PREST_JSON_AGG_TYPE` | `jsonb_agg` | changes how pREST encodes data from the database, can be set also to `json_agg` |
| `PREST_QUERY_GUARD_ENABLED` | `false` | rejects queries by execution plan. See [Query Guard](query-guard.md) |
| `PREST_QUERY_GUARD_REJECT_SEQ_SCAN` | `false` | refuses plans containing a sequential scan. See [Query Guard](query-guard.md) |
| `PREST_QUERY_GUARD_MAX_COST` | `0` | ceiling on the estimated total cost (`0` disables). See [Query Guard](query-guard.md) |
| `PREST_QUERY_GUARD_MAX_ROWS` | `0` | ceiling on the estimated row count (`0` disables). See [Query Guard](query-guard.md) |
Comment on lines +57 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document all Query Guard environment variables here.

The main configuration table lists only four settings, while get-started/query-guard.md also defines PREST_QUERY_GUARD_REJECT_PARALLEL_SEQ_SCAN, PREST_QUERY_GUARD_REQUIRE_INDEX_USAGE, and PREST_QUERY_GUARD_MAX_JOINS. Add those entries or explicitly link to the complete environment-variable table so this reference does not hide supported controls.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@get-started/configuring-prest.md` around lines 57 - 60, Expand the Query
Guard entries in the main configuration table to include
PREST_QUERY_GUARD_REJECT_PARALLEL_SEQ_SCAN,
PREST_QUERY_GUARD_REQUIRE_INDEX_USAGE, and PREST_QUERY_GUARD_MAX_JOINS, with
their defaults and descriptions matching get-started/query-guard.md;
alternatively, add a clear link to that complete environment-variable table.


### TOML

Expand Down Expand Up @@ -276,6 +280,12 @@ enabled = true

Or `PREST_STUDIO_ENABLED=false` to disable. See [pREST Studio](prest-studio.md).

### Query Guard

Optional protection for installations exposed to third parties: generated table reads are planned with `EXPLAIN (FORMAT JSON)` and refused with `422` when the plan violates rules you configure — sequential scans, cost and row ceilings, join count, or missing index usage. Disabled by default; policies can differ per database.

See [Query Guard](query-guard.md) for the full `[query_guard]` section and tuning guidance.

### Configuration resilience

Since **v2.0.0** ([#974](https://github.com/prest/prest/pull/974)), pREST does not abort startup because of configuration problems. Instead it logs warnings and applies safe fallbacks:
Expand All @@ -289,6 +299,8 @@ Since **v2.0.0** ([#974](https://github.com/prest/prest/pull/974)), pREST does n
| Invalid database registry entry | Entry skipped with warning |
| Unsafe JWT/auth config | JWT or auth auto-disabled with error log |

There is one deliberate exception: [Query Guard](query-guard.md) enabled on an adapter that cannot produce execution plans **aborts startup**. Falling back to "serve unprotected" would silently defeat the feature.

### Multi-database

pREST supports routing to multiple databases or clusters via a database registry. Set `pg.single = false` and configure `[[databases]]` entries or `DATABASE_ALIAS_N` / `DATABASE_URL_N` environment pairs.
Expand Down
177 changes: 177 additions & 0 deletions get-started/query-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
---
description: >-
Query Guard rejects expensive queries before PostgreSQL runs them — inspect the
execution plan, refuse sequential scans, cost and row ceilings, and require
indexed access on databases exposed to third parties.
---

# Query Guard

Query Guard inspects the **execution plan** of a generated query and refuses it when the plan violates a policy you configure. It is opt-in and disabled by default.

{% hint style="warning" %}
Available on unreleased [`prest/prest` `main`](https://github.com/prest/prest/tree/main). Not in a release tag yet — see [Changes since v2.1.0](../releases/main-since-v2.1.0.md).
{% endhint %}

## Why

[RBAC](permissions.md) answers *who* may read a table. It says nothing about *how expensive* the read is.

When you expose _**prestd**_ to third parties — vendors, BI tools, [AI agents](../ai/README.md) — a client filtering an unindexed column over a table with millions of rows produces repeated sequential scans and real database load. Without Query Guard the only defence is hand-writing [custom queries](../api-reference/custom-queries.md) for every endpoint.

## How it works

1. _**prestd**_ generates the SQL for the request as usual.
2. Before executing it, the query is planned with `EXPLAIN (FORMAT JSON)`.
3. The plan is matched against the configured rules.
4. A violation answers `422 Unprocessable Entity`; anything else runs normally.

`EXPLAIN ANALYZE` is never used, so the statement is planned but **not executed** during the check.

> Accepted queries pay one extra planning round trip. That is the cost of the protection — enable it where database load matters more than latency.

## Rejection response

```json
{
"error": "query rejected by Query Guard",
"reason": "Sequential Scan detected on table 'orders'.",
"rule": "reject_seq_scan"
}
```

`422` is used on purpose: the request is well formed and authorized (that would be `400` and `403`), but its execution plan is not acceptable.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use standard wording for the 422 explanation.

Replace “used on purpose” with “used deliberately” and hyphenate “well-formed” to resolve the documentation lint findings.

🧰 Tools
🪛 LanguageTool

[style] ~43-~43: Try using a descriptive adverb here.
Context: ... "reject_seq_scan" } ``` 422 is used on purpose: the request is well formed and authori...

(ON_PURPOSE_DELIBERATELY)


[grammar] ~43-~43: Use a hyphen to join words.
Context: ... is used on purpose: the request is well formed and authorized (that would be `40...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@get-started/query-guard.md` at line 43, Update the 422 explanation in the
documentation sentence by replacing “used on purpose” with “used deliberately”
and changing “well formed” to “well-formed”; leave the surrounding status-code
explanation unchanged.

Source: Linters/SAST tools


## Configuration

### TOML

```toml
[query_guard]
enabled = true # master switch, default false

reject_seq_scan = true # refuse full table scans
reject_parallel_seq_scan = true # refuse parallel sequential scans
max_cost = 50000 # planner total cost ceiling (0 = no limit)
max_rows = 100000 # estimated rows ceiling (0 = no limit)
require_index_usage = true # plan must read through an index
max_joins = 3 # join nodes ceiling (0 = no limit)
allow_tables = ["lookup", "countries"]
```

### Environment variables

| var | default | description |
| --- | ------- | ----------- |
| `PREST_QUERY_GUARD_ENABLED` | `false` | master switch |
| `PREST_QUERY_GUARD_REJECT_SEQ_SCAN` | `false` | refuse plans containing a sequential scan |
| `PREST_QUERY_GUARD_REJECT_PARALLEL_SEQ_SCAN` | `false` | refuse parallel sequential scans |
| `PREST_QUERY_GUARD_MAX_COST` | `0` | ceiling on the estimated total cost (`0` disables) |
| `PREST_QUERY_GUARD_MAX_ROWS` | `0` | ceiling on the estimated row count (`0` disables) |
| `PREST_QUERY_GUARD_REQUIRE_INDEX_USAGE` | `false` | plan must read at least one relation through an index |
| `PREST_QUERY_GUARD_MAX_JOINS` | `0` | ceiling on join nodes in the plan (`0` disables) |

A negative limit is treated as "no limit" instead of rejecting every query.

## Rules

| Rule | Refuses |
| ---- | ------- |
| `reject_seq_scan` | Any sequential scan, parallel ones included |
| `reject_parallel_seq_scan` | Parallel sequential scans specifically |
| `max_cost` | Plans whose root estimated cost exceeds the ceiling |
| `max_rows` | Plans whose root estimated row count exceeds the ceiling |
| `require_index_usage` | Plans that read no relation through an index |
| `max_joins` | Plans with more join nodes than allowed |

Scan rules are evaluated before cost rules, so the reported `reason` points at the actual cause.

### allow_tables

Relations listed in `allow_tables` are exempt from the **scan rules** (`reject_seq_scan`, `reject_parallel_seq_scan`, `require_index_usage`). Use it for small lookup tables where a full scan is genuinely cheaper than an index:

```toml
allow_tables = ["countries", "currencies", "status_codes"]
```

Cost and row ceilings still apply — they are properties of the whole query, not of one table.

## Per-database policies

With [multi-database](multi-database.md) deployments, each alias can relax or tighten the global policy. An override **starts from the global settings and replaces only the keys it declares**:

```toml
[query_guard]
enabled = true
reject_seq_scan = true
max_cost = 50000

# analytics keeps max_rows/require_index_usage from above,
# but allows sequential scans and a higher cost ceiling
[query_guard.databases.analytics]
reject_seq_scan = false
max_cost = 200000
```

Alias names are matched case-insensitively.

## What is checked

| Path | Guarded |
| ---- | ------- |
| `GET /{database}/{schema}/{table}` (table reads) | ✅ |
| MCP `select_table` tool ([MCP over HTTP](mcp-over-http.md)) | ✅ |
| `POST` / `PUT` / `PATCH` / `DELETE` on tables | ❌ — writes are not planned |
| `/auth` | ❌ |
| Catalog and listing endpoints (`/databases`, `/schemas`, `/tables`) | ❌ |
| [Custom queries](../api-reference/custom-queries.md) (`/_QUERIES`) | ❌ |

Only SQL generated from a client request is checked. Auth, catalog and custom-query SQL is written by _**prestd**_ or by you, not by API clients — checking it would, for example, break login on a `prest_users` table without an index.

## Requirements and startup behavior

Query Guard needs an adapter that can produce execution plans. PostgreSQL and its wire-compatible variants ([TimescaleDB](../databases/timescaledb.md), [Aurora](../databases/aurora-postgresql.md), [YugabyteDB](../databases/yugabytedb.md), [CockroachDB](../databases/cockroachdb.md), [Redshift](../databases/amazon-redshift.md)) qualify.

{% hint style="info" %}
If Query Guard is enabled and the adapter cannot plan queries, _**prestd**_ **fails to start**. This is deliberate: it is safer than serving an installation that believes it is protected. It is the one exception to [configuration resilience](configuring-prest.md#configuration-resilience).
{% endhint %}

When planning itself fails — for example the relation does not exist — the query is blocked and reported as `400`, not `422`: that is a database error, not a policy violation.

## Observability

Rejections are logged at `warn` level with the rule, the reason, the plan estimates and a compact plan shape:

```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Specify the log fence language.

Use a text language annotation for this log-output code block so Markdown linting can identify its contents correctly.

Proposed fix
-```
+```text
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 145-145: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@get-started/query-guard.md` at line 145, Update the log-output fenced code
block in the query guard documentation to specify the text language by changing
its opening fence to use the text annotation, while leaving the block contents
unchanged.

Source: Linters/SAST tools

level=WARN msg="query rejected by Query Guard" database=analytics rule=reject_seq_scan
reason="Sequential Scan detected on table 'orders'." estimated_cost=18342.5
estimated_rows=981230 plan="Seq Scan(orders)"
```

The SQL text is **not** logged: it carries user-supplied values. Set [`PREST_LOG_LEVEL`](configuring-prest.md#logging) to `warn` or lower to capture these.

## Tuning the policy

Start permissive and tighten:

1. Enable with `max_cost` only, set generously, and watch the rejection logs.
2. Lower `max_cost` until it matches what your database can actually absorb.
3. Add `reject_seq_scan` once you know which tables legitimately need full scans, and list those in `allow_tables`.
4. Add `require_index_usage` last — it is the strictest rule.

To find the cost of a query your clients run today, ask PostgreSQL directly:

```sql
EXPLAIN (FORMAT JSON) SELECT "id", "name" FROM "public"."orders" WHERE "status" = 'open';
```

The `Total Cost` of the top node is the value `max_cost` compares against.

## Related

- [Configuring pREST](configuring-prest.md)
- [Permissions](permissions.md)
- [Multi-database](multi-database.md)
- [MCP over HTTP](mcp-over-http.md)
- [Read-only PostgreSQL for AI](../ai/read-only-postgres.md)
- [Custom Queries](../api-reference/custom-queries.md)