feat(server): add POST /query endpoint for ad-hoc SQL - #158
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tementKind Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ws overflow Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EXPLAIN ANALYZE executed its wrapped statement, and SET mutated the shared SessionContext, both bypassing DDL/COPY/write-access checks. Recurse into EXPLAIN's inner statement and reject session-mutating SET. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PREPARE stored a write/DDL plan on the shared SessionContext and EXECUTE ran it later, bypassing the per-source access-mode gate. Ad-hoc one-shot queries have no use for prepared statements — reject both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
abbccdda
left a comment
There was a problem hiding this comment.
Automated review of the POST /query endpoint (7 finder angles, each finding independently verified). Comments below, most severe first. The most important one is the auth.sessions token read — worth resolving before merge. CSRF and an uncapped-Other-path candidate were both investigated and refuted (JSON content-type + wildcard-CORS-without-credentials + SameSite=Lax block the former; DataFusion rejects RETURNING at plan time for the latter).
…lowlist, scoped policy
- Forbid ad-hoc SQL from referencing the auth schema (auth.sessions holds
live bearer tokens); enforced via sqlparser visit_relations over every
relation (FROM/JOIN/subquery/CTE/DML target/EXPLAIN/DESCRIBE, incl.
3-part catalog.auth.table names).
- Invert the ad-hoc statement policy to a strict allowlist (Query,
access-checked DML, EXPLAIN-of-allowed, SHOW/DESCRIBE); unknown or
future sqlparser statement types (MERGE, GRANT, transactions, ...) are
rejected by default.
- Scope the new denials to /query: the pipeline-load path reverts to its
pre-PR policy (DDL blocked, DML access-checked, COPY/SET allowed), so
existing deployments keep booting.
- Skip {param} brace preprocessing for ad-hoc SQL — it rewrote string
literals and rejected valid queries.
- Match write-access checks on the fully qualified table name, honoring a
source's access mode for source.table references.
- Build the validator config once at startup into AppState instead of per
request (no runtime config writer exists).
- Log every ad-hoc statement before execution as an audit trail.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The validator previously parsed with a standalone sqlparser 0.53 while DataFusion 52.5 executes with 0.59 — a version skew that could let the two parsers disagree about a statement's shape. The workspace-root 0.55 entry had no users at all. Now a single workspace dep (0.59, visitor feature) is shared with DataFusion, so validation and execution always parse identically. Migration notes: - Insert targets are now TableObject; non-table-name targets (table functions) have no registered name to access-check. - ObjectName components are ObjectNamePart; identity comparisons use the bare ident value, which also fixes quoted identifiers slipping past access-mode and denied-schema checks (INSERT INTO "users", "auth"."sessions") — covered by a new test. - 'SELECT FROM users' (empty projection) parses in 0.59; dropped from the parse-error test, it fails at DataFusion planning instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
One clarification request on the design doc ( Related: the doc also predates the review follow-ups in b6ed69b (the |
Second review round — commits
|
…non-table INSERT targets Round-2 review fixes: - check_write_access now strips DataFusion's default catalog/schema qualifiers before lookup, so INSERT/UPDATE/DELETE against public.<table> or datafusion.public.<table> honors the same access mode as the bare name (they all resolve to the same table). A datafusion.<source>.<table> reference still honors the source's mode. - INSERT with a non-TableName target (ClickHouse INSERT INTO FUNCTION) is rejected instead of skipping the access check — unreachable via GenericDialect today, but the write path must fail closed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ser, tidy validator Second-round review follow-ups (#3–#10): - #4 Encode the trust boundary in the type. Ad-hoc /query now validates against a dedicated AdhocSqlPolicy (access modes + denied schemas); the trusted pipeline path keeps a bare SqlValidatorConfig. Reaching for validate_sql on untrusted input can no longer silently skip the allowlist + schema denial. - #5 Add coverage proving the schema denial descends into indirect relations (set ops, scalar/IN/EXISTS subqueries) via visit_relations; document the residual (operator-defined views/federated aliases, which ad-hoc SQL cannot create) and its structural fix. See follow-up issue. - #6 Parse via datafusion::sql::sqlparser (DataFusion enables the visitor feature); drop the standalone sqlparser dep entirely so validator and engine share one parser by construction — a DF bump is now a compile break, not silent parse divergence. - #8 Single AUTH_SCHEMA const used at both the register and deny sites. - #9 check_denied_schemas reports the table via extract_table_name (quote-stripped), matching WriteNotAllowed. - #10 statement_keyword maps the AST variant to a &'static str instead of Display-rendering the whole statement on each rejection. - #7 AppState::new derives the policy once; removes the copy-pasted Arc::new(validator_config_from_sources(..)) across ~9 sites. - #3 Document the startup-snapshot invariant (no runtime access_mode writer) on the field and the builder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Second-round general review — resolutions for findings #3–#10 (findings #1–#2 were fixed in #3 — startup snapshot of access modes. Kept the snapshot (it was round-1's explicit ask) and documented the invariant it rests on: no runtime writer mutates a source's #4 — trust boundary in the type, not the function name. Split the policy types. #5 — syntactic denylist over a shared context. Added coverage tests proving #6 — parallel sqlparser pin. Removed the standalone #7 — duplicated derived state. Added #8 — duplicated #9 — inconsistent table name in error. #10 — 🤖 Generated with Claude Code |
abbccdda
left a comment
There was a problem hiding this comment.
Follow-up: hardening the /query security story
Did another security-focused pass on the branch after the last review round. No exploitable HIGH/MEDIUM issue — the validator is tight (type-split trusted/untrusted policies, strict statement allowlist that fails closed on unknown/future statement types, auth-schema denial via visit_relations, default-qualifier stripping, quoted-identifier normalization, shared parser with the engine). The remaining items are defense-in-depth, left as inline comments below by value.
Happy to take the DELETE and error-shape items as a fast-follow.
…nternals Third-round defense-in-depth review: - Reject DELETE with a non-empty `tables` list or a `USING` clause on the ad-hoc /query path instead of relying on DataFusion to reject the form at plan time (only `delete.from` was access-checked before). - Stop returning raw engine/schema internals in /query error responses: the execution-error and JSON-conversion-error bodies now carry a generic message with no `details`, while the full DataFusion error and the record-batch schema stay in the server-side log. The auth-catalog isolation suggestion is tracked in #164 (the reviewer asked for an issue, not a block). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
abbccdda
left a comment
There was a problem hiding this comment.
Nice work!Can't wait to see this go live
Summary
Adds a
POST /queryendpoint to skardi-server that executes a single ad-hoc SQL statement against the data sources registered fromctx.yaml— bringing the CLI'sskardi query --sql "..."capability to the HTTP server.Design spec and implementation plan:
docs/superpowers/specs/2026-07-17-query-endpoint-design.md,docs/superpowers/plans/2026-07-17-query-endpoint.md.API
POST /querywith{ "sql": "...", "max_rows": 500 }(max_rowsoptional, default 1000). Returns the same JSON envelope as pipeline execution, plus atruncatedflag:{ "success": true, "data": [...], "rows": 500, "truncated": true, "execution_time_ms": 42, "timestamp": "..." }Statement policy
Enforced by the existing
sql_validator, run per request against the currentctx.yamlaccess modes:access_mode: read_write;read_only(the default) is rejected.EXPLAIN ANALYZE <write>,SET, andPREPARE/EXECUTEare rejected — these otherwise smuggle writes/DDL past the access-mode gate via the sharedSessionContext.Result cap
Default 1000 rows, overridable via
max_rows(no server-side maximum). The cap is pushed into the DataFusion plan via a newDataFusionEngine::execute_with_limit; the endpoint fetchesmax_rows + 1to detect truncation, then slices. Overflow ofmax_rowsis guarded (saturating_add+i64::MAXclamp for DataFusion'si64LIMIT literal).Other changes
pipeline_handlers.rsinto a sharedresponse.rs; pipeline responses remain byte-identical.require_sessionauth helper used by both the query and pipeline handlers.Testing
execute_with_limittruncation / full-result / empty-result.crates/server/tests/query_http.rs): 15 passing — SELECT envelope, truncation boundaries, DDL/COPY/multi-statement/parse rejection, read-only vs read-write DML,EXPLAIN ANALYZE/PREPAREbypass rejection,usize::MAXoverflow guard, execution error, and a 401 with the real better-auth layer.cargo fmt --allclean;cargo test --workspacegreen.Notes for reviewers
The validator is a denylist (
_ => Ok(())catch-all with explicit rejections). Three same-class bypasses (EXPLAIN ANALYZE, SET, PREPARE/EXECUTE) were found and patched during review. A follow-up to convert it to an explicit read-only allowlist — to prevent a future DataFusion/sqlparser bump from reintroducing an executable statement type — is worth tracking separately.🤖 Generated with Claude Code