Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Security

- **A role's structured-query resource caps are now enforced server-side by ClickHouse, so a public read can't outrun its budget during a server-side scan/merge/aggregation phase** (`internal/api/ch_settings.go` (new), `internal/api/structured_query.go`, `internal/policy/policy.go`, `internal/policy/scalars.go` (new), `internal/config/config.go`, `internal/query/builder.go`, `cmd/wavehouse/main.go`, `config.yaml`, `clients/ts/src/types.ts`, `docs/src/content/docs/{access-control.mdx,configuration.mdx}`, plus tests in `internal/api/ch_settings_test.go`, `internal/policy/{policy,scalars}_test.go`, `internal/config/config_test.go`, `internal/query/builder_test.go`, `tests/integration/query_limits_test.go` (all new/expanded), `tests/e2e/sdk/query.test.ts`): closes #316. The native ClickHouse connection passed **no per-query `Settings`**, so a read's policy caps bound it only client-side — a Go `context` deadline (which cancels only while the client is reading result blocks) plus a SQL `LIMIT`. Memory and rows scanned were **never bounded server-side**, so a heavy aggregation could allocate gigabytes of state or scan an entire table well within the time budget; and because `clickhouse-go` derives a `max_execution_time` setting from the context deadline only for deadlines `> 1s`, a sub-second time cap reached ClickHouse with no server-side time bound at all. The structured-query path now attaches per-query `Settings` derived from the role's resolved permissions: `max_execution_time` (fractional seconds, emitted explicitly so the sub-second case is enforced), `max_result_rows` + `result_overflow_mode=throw` (defense-in-depth behind the SQL `LIMIT`), `max_rows_to_read` + `read_overflow_mode=throw`, and `max_memory_usage` — so a query that exceeds its budget is rejected by the server (ClickHouse codes 158 / 241) rather than running to completion. **Boundary:** WaveHouse owns the *dynamic, per-role* caps (sent as per-query settings); the *global, static* backstop — which applies to every query including named pipes and raw admin SQL — is configured in **ClickHouse's own settings profiles and quotas** (documented in `configuration.mdx`), composes with the per-role caps, and holds even against a WaveHouse bug. **Schema (pre-launch):** the per-role policy fields are human-readable in / numeric out — `max_execution_time_ms` (int) → **`max_execution_time`** (set as a duration string `"5s"` or a bare ms number; read back as ms), the new **`max_memory_usage`** (set as a size string `"4GiB"` — IEC/SI respected via `dustin/go-humanize`, so `4GB` ≠ `4GiB` — or a bare byte number; read back as bytes), and the new **`max_rows_to_read`** (int), backed by two small `Millis`/`ByteSize` types in the `policy` package. The formerly hard-coded `query.DefaultMaxRows = 10000` result-LIMIT becomes the documented, tunable `query.default_max_rows` config knob (`Build` takes it as a parameter). Raw admin SQL remains unbounded by WaveHouse (governed by ClickHouse). Verified RED before the fix: the handler-level integration test confirmed a capped read returned the full result set when the settings weren't sent.
- **The structured-query column allowlist is now a hard cap on every column a query references — closing a fail-open data-exposure family on the primary read path** (`internal/query/builder.go`, `internal/query/errors.go` (new), `internal/policy/policy.go`, `internal/discovery/discovery.go`, `internal/api/structured_query.go`, `docs/src/content/docs/access-control.md`, `docs/src/content/docs/api.md`, plus tests in `internal/query/builder_test.go`, `internal/api/structured_query_test.go`, `internal/policy/policy_test.go`, `internal/discovery/discovery_test.go`, `tests/e2e/sdk/query.test.ts`): closes #223. `POST /v1/query` previously authorized only the columns a caller *explicitly* listed, so several inputs bypassed a role's `allow_columns`/`deny_columns` entirely:
- **Omitting `columns` (or sending `[]` / `["*"]`)** made the builder emit `SELECT *`, returning every column — the reported leak (an unauthenticated `{"limit":2}` returned columns hidden from the public role, including raw webhook `payload`).
- **`group_by` on a denied column** (`SELECT count(*) … GROUP BY salary`) enumerated that column's distinct values wholesale; a **`filter`** on a denied column turned the returned row count into a value-inference oracle; an **`order_by`** on a denied column leaked ordering. None of these clauses were policy-checked — only the SELECT list and aggregation arguments were.
Expand Down
16 changes: 15 additions & 1 deletion clients/ts/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,22 @@ export interface RolePermissions {
check?: Record<string, PolicyFilter>;
allowed_aggregations?: string[];
denied_aggregations?: string[];
/** Caps the query result LIMIT. 0 = no limit. */
max_rows?: number;
max_execution_time_ms?: number;
/**
* Max query execution time, enforced server-side by ClickHouse. Set it as a
* duration string ("5s", "500ms") or a number of milliseconds; reads always
* return the number of milliseconds.
*/
max_execution_time?: number | string;
/** Max rows scanned from storage, enforced server-side by ClickHouse. 0 = no limit. */
max_rows_to_read?: number;
/**
* Max peak query memory, enforced server-side by ClickHouse. Set it as a size
* string ("4GiB", "512MiB") or a number of bytes; reads always return the
* number of bytes.
*/
max_memory_usage?: number | string;
}

export interface PolicyFilter {
Expand Down
2 changes: 1 addition & 1 deletion cmd/wavehouse/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ func run() int {
DLQ: dlqHandler,
Policy: api.NewPolicyHandler(policyStore),
Pipes: api.NewPipesHandler(pipesStore, policyStore, chConn, cache, cfg.ClickHouse.QueryTimeout, logger),
StructuredQuery: api.NewStructuredQueryHandler(chConn, cache, registry, policyStore, cfg.Cache.TimestampBucketSeconds, cfg.ClickHouse.QueryTimeout, logger),
StructuredQuery: api.NewStructuredQueryHandler(chConn, cache, registry, policyStore, cfg.Cache.TimestampBucketSeconds, cfg.ClickHouse.QueryTimeout, cfg.Query.DefaultMaxRows, logger),
AuthMW: authMW,
PolicyStore: policyStore,
Logger: logger,
Expand Down
7 changes: 7 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ clickhouse:
password: ""
query_timeout: 30s

# Query-shaping defaults. Server-wide RESOURCE limits (memory, rows scanned,
# execution time) live in ClickHouse itself — its settings profiles and quotas
# — so they apply to every query uniformly; see docs/configuration. This block
# holds only the result-LIMIT default.
query:
default_max_rows: 10000 # fallback result LIMIT (<=0 falls back to 10000)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
mq:
gap_window_minutes: 15
max_bytes_gb: 50
Expand Down
38 changes: 27 additions & 11 deletions docs/src/content/docs/access-control.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -293,20 +293,28 @@ A disallowed function returns `403 aggregation "x" not allowed`. This is useful

## Resource limits

Two fields cap the cost of a single structured query. Both must be non-negative; `0` (the default) means "no policy-imposed limit".
Four fields cap the cost of a single structured query for this role. All must be non-negative; `0` / unset means "no role-imposed limit" — the [server-wide ClickHouse limits](#server-wide-limits-live-in-clickhouse) still apply, and for `max_rows` the `query.default_max_rows` result default (10,000) still clamps the `LIMIT`.

| Field | Effect |
| ----- | ------ |
| `max_rows` | Caps the query's `LIMIT`. If the caller asks for more (or omits a limit), the result is clamped to `max_rows`. |
| `max_execution_time_ms` | Caps the ClickHouse execution timeout for the query, applied as the *minimum* of this value and the server's `clickhouse.query_timeout`. |
| `max_execution_time` | Caps the query execution time, applied as the *minimum* of this value and the server's `clickhouse.query_timeout`. |
| `max_rows_to_read` | Caps the rows **scanned from storage** server-side — the lever that stops a full-table scan. The read is rejected once it is exceeded. |
| `max_memory_usage` | Caps **peak query memory** server-side — the lever that stops a heavy aggregation from exhausting the box. The read is rejected once it is exceeded. |

`max_execution_time`, `max_rows_to_read`, and `max_memory_usage` are enforced **server-side by ClickHouse** (sent as per-query `max_execution_time` / `max_rows_to_read` / `max_memory_usage` settings), so a query can't outrun its budget during a server-side scan, merge, or aggregation phase — not just while the client is reading rows back. `max_rows` is applied as the SQL `LIMIT` (and mirrored server-side as `max_result_rows` for defense-in-depth).

**`max_execution_time` and `max_memory_usage` accept a human-readable value or a number.** Set `max_execution_time` as a duration string (`"5s"`, `"500ms"`) or a bare number of **milliseconds**; set `max_memory_usage` as a size string (`"4GiB"`, `"512MiB"` — IEC vs SI is respected, so `"4GB"` is 4×10<sup>9</sup> and `"4GiB"` is 4×2<sup>30</sup>) or a bare number of **bytes**. The API always **returns** them as those numbers (ms and bytes), so SDK consumers read a plain integer.

<Tabs syncKey="policy">
<TabItem label="YAML">
```yaml
select:
viewer:
max_rows: 1000
max_execution_time_ms: 5000
max_execution_time: "5s"
max_rows_to_read: 50000000 # reject a read that scans > 50M rows
max_memory_usage: "4GiB" # peak memory per query
```
</TabItem>
<TabItem label="JSON">
Expand All @@ -315,7 +323,9 @@ select:
"select": {
"viewer": {
"max_rows": 1000,
"max_execution_time_ms": 5000
"max_execution_time": "5s",
"max_rows_to_read": 50000000,
"max_memory_usage": "4GiB"
}
}
}
Expand All @@ -325,20 +335,24 @@ select:

These bound a role's blast radius on the cached read path. They do not apply to raw admin SQL, which is unbounded by design (other than the 64 MiB response cap noted in the [API reference](/api)).

### Server-wide limits live in ClickHouse

These policy fields are **per-role** caps. A **server-wide** backstop — one that applies to *every* query regardless of role, including named pipes and raw admin SQL — is not part of the WaveHouse policy: configure it in ClickHouse's own [settings profiles and quotas](/configuration#server-side-resource-limits), where it is enforced natively and composes with the per-role caps above. That keeps global resource governance in one authoritative place and holds even against paths the policy engine doesn't touch.

## Where each rule is enforced

The same policy drives every data path, but not every field is meaningful on every path:

| Surface | Endpoint | Enforced |
| ------- | -------- | -------- |
| Structured read | `POST /v1/query?table={table}` | table+role `select` required, then `allow`/`deny_columns`, row `filter`, aggregation rules, `max_rows`, `max_execution_time_ms` |
| Structured read | `POST /v1/query?table={table}` | table+role `select` required, then `allow`/`deny_columns`, row `filter`, aggregation rules, and the per-role `max_rows` / `max_execution_time` / `max_rows_to_read` / `max_memory_usage` caps (over the [ClickHouse server-wide limits](/configuration#server-side-resource-limits)) |
| Ingest (write) | `POST /v1/ingest?table={table}` | table+role `insert` required, then `allow`/`deny_columns` and `check` (enforced and auto-injected) |
| Live stream | `GET /v1/stream` | table+role `select` required (a table the role can't read is skipped), then denied columns are masked from each event |
| Raw SQL | `POST /v1/admin/query` | `admin_role` only — no per-statement policy; the role gate is the entire authorization story |
| Named pipe | `GET/POST /v1/pipes/{name}` | not the policy engine — per-pipe `allowed_roles`; see [Named Pipes](/pipes) |
| Named pipe | `GET/POST /v1/pipes/{name}` | per-pipe `allowed_roles` (not the policy engine; see [Named Pipes](/pipes)). Resource limits come from ClickHouse's [server-wide settings](/configuration#server-side-resource-limits), not per-role policy caps |

:::caution[Live streams enforce access, not row filters]
SSE subscribers are checked for table-level `select` permission and have denied columns stripped from each event, but the row-level `filter` predicates and `max_rows`/`max_execution_time_ms` limits are a property of the SQL query path and are **not** applied to the live event stream. If a role must never observe another tenant's rows in real time, don't grant it stream access to a shared table — scope the data at the table level.
SSE subscribers are checked for table-level `select` permission and have denied columns stripped from each event, but the row-level `filter` predicates and the resource limits (`max_rows`, `max_execution_time`, `max_rows_to_read`, `max_memory_usage`) are a property of the SQL query path and are **not** applied to the live event stream. If a role must never observe another tenant's rows in real time, don't grant it stream access to a shared table — scope the data at the table level.
:::

## Managing the policy
Expand Down Expand Up @@ -415,7 +429,7 @@ tables:
_eq: "{{ jwt.app_metadata.tenant_id }}"
denied_aggregations: ["quantile", "median"]
max_rows: 1000
max_execution_time_ms: 5000
max_execution_time: "5s"
insert:
writer:
# Clients send business fields; the policy stamps identity from the token.
Expand Down Expand Up @@ -444,7 +458,7 @@ tables:
},
"denied_aggregations": ["quantile", "median"],
"max_rows": 1000,
"max_execution_time_ms": 5000
"max_execution_time": "5s"
}
},
"insert": {
Expand Down Expand Up @@ -486,8 +500,10 @@ Per-role permissions (`tables.<table>.select.<role>` and `.insert.<role>`):
| `check` | map | insert | Required insert values (`_eq` only). Enforced if present in the body, auto-injected if absent. Supports templating. |
| `allowed_aggregations` | string[] | select | Allowlist of aggregation functions. Empty = all (minus denied). Case-insensitive. |
| `denied_aggregations` | string[] | select | Blocklist of aggregation functions. Always wins. |
| `max_rows` | int | select | Caps the query `LIMIT`. `0` = no limit. Must be non-negative. |
| `max_execution_time_ms` | int | select | Caps the query timeout (min with `clickhouse.query_timeout`). `0` = no limit. Must be non-negative. |
| `max_rows` | int | select | Caps the query `LIMIT`. `0` = no limit (the `query.default_max_rows` config default applies). Must be non-negative. |
| `max_execution_time` | duration or ms | select | Caps the query timeout (min with `clickhouse.query_timeout`). Set as `"5s"`/`"500ms"` or a number of ms; returned as ms. `0` = no limit. Must be non-negative. |
| `max_rows_to_read` | int | select | Caps rows **scanned** server-side (ClickHouse `max_rows_to_read`); the read is rejected once exceeded. `0` = no role limit. Must be non-negative. |
| `max_memory_usage` | size or bytes | select | Caps peak query memory server-side (ClickHouse `max_memory_usage`). Set as `"4GiB"`/`"512MiB"` or a number of bytes; returned as bytes. `0` = no role limit. Must be non-negative. |

## See also

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ Every column the query references — in `columns`, an aggregation argument, `fi
| `filters` | object[] | No | WHERE conditions (`column`, `op`, `value`). Ops: eq, neq, gt, gte, lt, lte, in, like. |
| `group_by` | string[] | No | GROUP BY columns. |
| `order_by` | object[] | No | ORDER BY clauses (`column`, `dir`). |
| `limit` | int | No | Max rows. Omitted or above 10,000 → silently capped at 10,000 (`DefaultMaxRows`); a policy `max_rows` can lower it further (see [Access Control](/access-control#resource-limits)). |
| `limit` | int | No | Max rows. Omitted or above the configured `query.default_max_rows` (default 10,000) → silently capped at that value; a policy `max_rows` can lower it further (see [Access Control](/access-control#resource-limits)). |
| `time_range` | object | No | Time window (`column`, `since`, `until`). `since`/`until` accept RFC3339 or Go-duration relative values ("1h", "30m", "7d", "2w" — day and week suffixes expand to hours). Relative values mean that long *ago*. The window applies only when `column` and `since` are set — an `until` without `since` is ignored. |

:::note[Identifier names]
Expand Down
Loading