Repository navigation
fix(sql): schema-changing statements require tinycloud.sql/schema (TC-1581, TC-1585, TC-1586) - #389
Conversation
…-1581) sql/write now covers data statements only. CREATE, ALTER and DROP of tables, views and indexes need a schema-conferring ability (schema, admin, *). Authorization is decided per statement against every SQL ability the invocation holds instead of one preferred ability, on every request path (query, execute, execute.schema[], batch, named statements), and by the SQLite authorizer, which also refuses CREATE TRIGGER. The quota gate classifies writes independently of abilities. [global.storage.sql] ddl_requires_schema = enforce (default) | log | off.
A WITH ... INSERT / UPDATE parses as a Query, so it was classed read-only: no quota gate, no write-hook events, no touched table (and therefore no durable save). Any statement tree that contains an INSERT or UPDATE is now a write. WITH ... DELETE is still rejected by the parser for every ability.
…ments do not write sqlite_master (TC-1586) The actor's SQLite connection is shared by every request on a database. After a request that held a PRAGMA, return the connection to the state it was opened in (writable_schema, query_only, foreign_keys, ignore_check_constraints, recursive_triggers, case_sensitive_like, WAL mode for file-backed databases). The authorizer no longer lets a data statement write sqlite_master, sqlite_schema or their temp counterparts: only a schema change, a pure query (pragma_* table-valued functions read the schema through them) and admin do.
Keep SqlService::execute / execute_without_growth, parser::validate_sql, authorizer::create_authorizer, DatabaseHandle::execute and sql_request_is_write as they were. The all-abilities entry points are new functions beside them (execute_authorized, execute_without_growth_authorized, validate_sql_authorized, create_authorizer_scoped, execute_authorized on the handle). storage.rs is untouched; the connection reset lives in database.rs. Existing tests keep their text except where they pinned write-ability DDL.
|
CHANGES REQUESTED at 90fd2a3 Independent Opus review of #389 (TC-1581, TC-1585, TC-1586) at this exact head. The reviewer session ended on a tool error while writing its notes, so this comment was posted by the Node lead from the reviewer's findings. The gate is a fresh Opus review at the next head. Details are deliberately high-level; no reproduction text is posted. MAJOR-1: held-set handling of unregistered MAJOR-2: the TC-1586 reset is incomplete. (a) It resets only file-backed databases; connection-level state on in-memory databases persists across requests, including state that defeats the growth guard and the in-memory-to-file promotion. (b) Other connection-level settings are not reset. (c) Detection is by the first token of a statement, which can miss a PRAGMA that is not in first position. (d) A reset failure is only logged, so the path fails open. Fix: reset unconditionally after any request that was allowed a non-whitelisted PRAGMA (or restrict admin PRAGMAs to an allow-list in the authorizer); fail closed by retiring the actor if the reset fails; cover in-memory databases. Tests: a state change by an admin request is not visible to a following write-only request on the same database, for both file-backed and in-memory databases. MAJOR (pre-existing, not introduced here): process-wide SQLite settings reachable by an MINOR/NIT. Block-comment nesting differs between the SQL parser and SQLite in request Verified OK. The DDL gate (parser and authorizer) over Query, Execute.sql, Execute.schema[], Batch and ExecuteStatement, including case, comments, BOM, WITH, EXPLAIN and multi-statement input; DDL needs schema, admin or |
has_non_schema_ability and is_read_tier_only now consider only abilities that confer read, write or admin through ability_matches. A holder of schema plus an unrecognised ability is schema-only, as before TC-1581. The schema-only check is a new is_schema_only (schema conferred, no data access).
…ys reset Connection state on a database's shared connection must not outlive the request that set it. The authorizer now allows an admin only a short PRAGMA allow-list (header values, checks, read-only reports). After any request in which a PRAGMA outside the plain schema reports was allowed, the connection is reset for in-memory and file-backed databases alike; if the reset fails, the actor is retired and the next request rebuilds it.
SQLite touches the schema table with one UPDATE on first use of a pragma_* table-valued function. A statement the parser classified as a pure read no longer gets INSERT or DELETE on those tables.
SQLite ends a comment at the first */ and the SQL parser nests them, so the two could read different statements. The check the PRAGMA path already had now runs for all statements. Also serialises the tests that read the process-wide log-mode counter, which raced under parallel test runs.
…sts, PRAGMA allow-list
|
APPROVE at 1eecc62 Independent Opus security review, round 2, of #389 (TC-1581, TC-1585, TC-1586) at this exact head (base main e8926a6). The round-1 findings at 90fd2a3 are fixed. I found no blocker or major. Below are two minors and two nits; none of them blocks merge. No reproduction text is posted. Round-1 findings
FindingsMINOR-1: the retire path closes its connection after deregistering (
MINOR-2 (pre-existing, now documented as supported): admin header-value sets are not made durable (
NIT-1: NIT-2 (pre-existing on main): What I ran (CARGO_TARGET_DIR shared scratch, no Docker)
Verified OK (full pass)
|
The fixture runs the account-index DDL, so its session delegates and its invocation hold write and schema together, as the SDK sends for that flow. The round-trip budgets are unchanged.
|
APPROVE at e146b55 Independent re-review of the delta from the approved head 1eecc62.
The round-1/round-2 approval carries to this head. |
Main's actor calls handle_message_authorized, so the request transaction wraps there (handle_request_authorized is main's former body). Fixtures that create tables hold admin; the PRAGMA test uses user_version (allow-listed). Adds a test that a WITH ... INSERT in a failed batch leaves nothing.
…491/1a-local-durability (TC-1491) The wrap, gate and reservation decision (needs_transaction) uses classify_sql (TC-1585 nested writes) and the request authority; the budget path has an authority entry point (execute_with_budget_authorized) that the route calls. Adds tests: WITH ... INSERT over budget/cap/disk is refused before COMMIT with no effects (core), and a route test over the remaining budget.
Linear: TC-1581 (with TC-1585 and TC-1586, each its own commit; the last commit only restores existing signatures).
What changes
tinycloud.sql/writecovers data statements. Statements that change the schema (CREATE,ALTERandDROPof tables, views and indexes, includingCREATE TABLE ... AS SELECTand temp objects) need a schema-conferring ability:sql/schema,sql/adminorsql/*.PRAGMAis unchanged (stilladmin). SQLite DDL only; DuckDB is a separate change.The node used to choose one "preferred" ability per invocation (
writebeforeschema). It now judges each statement against every SQL ability the invocation holds: schema statements need a schema-conferring ability, data statements keep theirwrite/readtiers, and a request that mixes both needs both (oradmin/*). This coversquery,execute, theschemalist ofexecute,batchand named statements. The SQLite authorizer enforces the same rule on its own callbacks (and now also refusesCREATE TRIGGER), so a statement the parser misclassifies is still refused. The storage-quota gate classifies a statement as a write whoever may run it.[global.storage.sql] ddl_requires_schema = "enforce" | "log" | "off", defaultenforce(no compose change, no migration, no version bump).logruns the statement and records it: one constant warning per request and a countertinycloud_ddl_without_schema_total{service,kind}, with no SQL text, space ids, DIDs or table names.offkeeps the earlier behaviour. Seedocs/sql-schema-authority.mdandCHANGELOG.md.Commits
WITH ... INSERT/UPDATEparses as a Query and was classed read-only, so it skipped the quota gate, emitted no write hook, reported no touched table (and so was not persisted by the durable save). Any statement tree containing a write is now a write.WITH ... DELETEis still not accepted by the parser.PRAGMA, the connection is returned to its opening state (writable_schema,query_only,foreign_keys, ...), and the authorizer no longer lets a data statement writesqlite_master/sqlite_schema(a schema change, a pure query andadminstill may).For PRs rebasing on this (SQL-B)
Files touched in
tinycloud-core/src/sql/:authority.rs(new),authorizer.rs,database.rs,parser.rs,service.rs,mod.rs(two lines).storage.rsis untouched. Elsewhere:tinycloud-node-server/src/routes/mod.rs(the/invokeSQL path and tests),config.rs,lib.rs(one builder call),tinycloud.toml, docs, CHANGELOG, and test fixtures (below).Existing signatures are unchanged:
SqlService::executeandexecute_without_growth(still take a singleStringability),parser::validate_sql(sql, caveats, &str),authorizer::create_authorizer(caveats, String, bool),DatabaseHandle::execute,sql_request_is_write(request, caveats, ability)(the ability is no longer consulted), and the in-module test helpershandle_message/handle_message_without_growth(now#[cfg(test)]wrappers).New or changed pub items:
SqlAuthority,DdlPolicy(re-exported fromsql),DdlKind,record_ddl_without_schema,ddl_without_schema_countsql/authority.rsSqlService::with_ddl_policy,execute_authorized,execute_without_growth_authorizedsql/service.rsDatabaseHandle::execute_authorizedsql/database.rsvalidate_sql_authorized,classify_sql,SqlClass;ParsedQuerygainsddl_without_schema: Option<DdlKind>sql/parser.rscreate_authorizer_scoped,StatementScopesql/authorizer.rsSqlStorageConfig::ddl_requires_schemaconfig.rsBehaviour to adapt to:
classify_sql(sql)answers "is this a write / DDL" without any ability, andParsedQuery.write_targets/is_read_onlynow cover writes nested in a query tree (TC-1585). New code that decides "is this a write" should use these.CREATE/ALTER/DROPwith onlysql/writeis now refused. Usesql/schemaorsql/adminfor the fixture (orexecute_authorizedwith[write, schema]).DbMessage::Executecarriesauthorityinstead ofability;handle_message_authorized/handle_message_without_growth_authorizedare the real implementations.Migration
sql/schemafor DDL since js-sdk 2.5.0 and sends[write, schema]forexecute({ schema })and migrations. Every SDK pin found is >= 2.5.tinyboilerplateexamples/notes(manifest needsschema) andlistenotter-importer.actionContainsignoringadminimplyingschemais a separate js-sdk fix.logwarning is not mapped inlog-shipper/diagnostics.json, so it ships as an unclassifiednode_warninguntil it is. Mapping it changes the compose hash and is not part of this PR.Tests
[write, schema], admin,*, read) in the parser, the authorizer and every request path; mixedexecute{schema, sql}and batch; named statements; theoff/log/enforceswitch; an authorizer test with a deliberately misclassifying scope; PRAGMA unchanged; data statements onwritealone (including CTE-wrapped). New tests are in newddl_authority_testsmodules; the existing test text is kept except for four authorizer tests that assertedwritecould run DDL./invokeroute tests: a[write]invocation withCREATE/DROP/ALTERis 403, a[write, schema]one passes,schemaalone runs DDL only; CTE-wrapped writes on a full space are 402.writenow usesql/admin(service/route seeds) or holdschema(HTTP sessions); thetest/e2e crates were updated the same way.Not run here: the PostgreSQL jobs (CI only).
Review round 2 (commits 4081f17..1eecc62)
tinycloud.sql/*abilities confer nothing: they no longer turn schema-only into "has a data ability" or a read grant into a data grant (is_schema_only,is_read_tier_only).pragma_*functions, not INSERT or DELETE.off,logcounts parser-detected DDL only, mixedschema+sqlrequests are not transactional, the PRAGMA allow-list.CI fixture fix (e146b55)
postgres_round_trip_budgetruns the account-index DDL, which the SDK sends with[write, schema]. The fixture delegated and invokedsql/writeonly, so it was correctly refused. Its session now delegates read, write and schema, and the account SQL invocation holds[write, schema](newinvocation_withhelper). Test-only:tinycloud-node-server/tests/postgres_round_trip_budget.rs.Verified against a local PostgreSQL 14 (the CI job uses 16):
postgres_round_trip_budgetpasses with the budgets unchanged (sign-in 77, kv put 38, kv get 22, kv batch get 22); it failed with the CI message before the change.Audit of every test that runs DDL or is Postgres-gated (run against that local Postgres):
schema/adminfrom the earlier commits:postgres_pool_timeouts,request_telemetry_authenticated,space_size_restart, thetest/e2e crates, coredata_owners.postgres_connection_recovery(its DDL is Postgres-side, not the SQL service),postgres_kv_write_throughput(ignored),admin_stats,kv_batch_read,request_telemetry,sql_identity_cli,at_rest_harness, coresqlx_request_telemetryandembedded_request_telemetry, the corepostgres_*lib tests, and the node lib tests (613).src/(webhook_dispatcher,policy_v3/test_harness, coretest_support) use no SQL ability.blackhole_*tests; coredb::test::postgres_space_filter_survives_a_collation_that_ignores_the_sentinelneeds PostgreSQL 16 collation rules and fails on 14 (it does not touch SQL abilities or any file in this PR).test/probe/write-probe.tsandpersonal-node-check.tsrunCREATE TABLEthrough the SDK; they depend on the SDK session holdingschema(js-sdk >= 2.5), so no Rust fixture applies. Not run here.