Skip to content

sql: enforce delegated table, column and read-only caveats (SQLite) - #425

Merged
samgbafa merged 7 commits into
mainfrom
tc1791/delegated-sql-caveats-sqlite
Oct 23, 2026
Merged

samgbafa merged 7 commits into
mainfrom
tc1791/delegated-sql-caveats-sqlite

Conversation

@samgbafa

@samgbafa samgbafa commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

What this does

A tinycloud.sql/* delegation can carry tables, columns and readOnly caveats. The node admits them when a delegation is registered, intersects them over the stored delegation chain an invocation cites, and enforces the result for the whole SQL request in the SQLite authorizer. Everything is behind [global.sql] delegated_caveats_enforced (env TINYCLOUD_SQL__DELEGATED_CAVEATS_ENFORCED), default off and commented out in tinycloud.toml. On: /info features lists sql-delegated-caveats-v1. DuckDB: tinycloud.duckdb/* takes empty caveats only.

Docs: docs/sql-delegated-caveats.md. Changelog entry under [Unreleased].

Grammar (per ability, wire = list of objects; the node sees entries keyed "0", "1", ...)

  • {} no restriction; a constrained-statements profile (mode key, or nested constrained-statements) is read by the profile rules; or the delegated form: keys exactly a subset of tables, columns, readOnly.
  • A list with a delegated entry has exactly one entry. Two entries (even {} beside a form) are refused. A list of only empty entries restricts nothing. mode together with tables or columns is refused.
  • Pinned name rules: 1..=256 elements per list; each element 1..=128 bytes of UTF-8; no .; no control character; no leading or trailing Unicode whitespace. Interior spaces and non-ASCII are accepted. Matching is ASCII-case-insensitive (only ASCII letters fold; the Kelvin sign U+212A is not k) and exact otherwise: no wildcards, no table.column.
  • readOnly is a boolean; false equals absent. tables: [] allows no table.

Rules (setting on)

  • Per hop (/delegate, the existing caveats_contain_child, case folded on both sides): a child's tables / columns are subsets of the parent's (absent parent axis = anything; a child that drops a restricted axis is refused); readOnly may be turned on, not off. Refusal: 401 child-caveats-not-subset-of-parent: <axis>. A grant issued by the space owner is unconstrained.
  • Per request (/invoke): effective tables / columns = intersection over every stored delegation reachable from the cited CIDs whose ability and resource cover the invoked capability; readOnly = OR. A hop without the ability contributes nothing. It does not depend on each hop having passed the per-hop check. The invocation's sqlCaveats fact only narrows (readOnly: false does not lift a readOnly chain); a request with only a fact, nothing on the chain, is judged by the same rules. The one value is passed to sql_request_is_write, the transaction decision and the authorizer, for raw and named statements alike. Invocations carry no delegations of their own.
  • Authorizer (abilities first, then caveats; precedence write, then table, then column; the arm is read from a tracker on the request's SqlAuthority): a column-less read (count(*)) is allowed when the table is; under columns INSERT and DELETE are refused and UPDATE needs its columns (and the WHERE columns) listed; readOnly refuses writes, DDL, a PRAGMA with a value, and a statement the parser does not classify read-only (at its first callback, whatever the action); tables reached through triggers, foreign keys and views, sqlite_master and pragma_* functions must be listed; a statement that creates, alters or drops a table, index, view or trigger needs that table or view listed.
  • Response: 403 with the text sql_caveat_violation: write, table or column; no SQL, table or column name. A request with a refused statement is refused whole (the request transaction rolls back; no repository call).

With the setting off

The setting is passed to every place that reads the delegated grammar; with it off none of them reads it. Observable differences from main:

  • Existing paths (responses, statuses, text): none. /delegate admission, per-hop containment, invocation authorization (verify_and_authorize, authorize_admitted, process, process_admitted, authorize_holder), handle_sql_invoke (no chain evaluation, facts used as given, export allowed), the authorizer and the parser's caveat check (no delegated rules, no arm recorded), /info features, and configuration (a missing [global.sql] section is the default) run the code they run without this feature. SqlError::CaveatViolation cannot occur.
  • /delegate with a delegated-form caveat (tables, columns, readOnly entry, or two empty entries): 401 unsupported-caveat: <ability> caveat <index> is not a constrained-statements profile, nothing stored. Tests delegated_sql_caveats_are_refused_when_the_setting_is_off (route, exact body) and with_the_setting_off_only_empty_caveats_and_profiles_are_accepted (core).
  • /info: the features list has no new string; test sql_delegated_caveats_feature_is_advertised_only_behind_its_setting asserts the on list is the off list plus the one string.
  • Stored rows with a non-profile caveat on a tinycloud.sql/* ability (written by an older node, or while the setting was on): an invocation whose only matching row is such a row is refused 401 invocation-caveats-not-subset-of-chain, and nothing runs; a row of this kind beside a good row for the same ability is ignored. Tests stored_rows_are_judged_by_the_profile_rules_when_the_setting_is_off (route), stored_delegated_form_covers_an_invocation_only_with_the_setting_on, stored_grant_of_only_empty_entries_covers_any_invocation_only_with_the_setting_on and delegated_form_hops_are_contained_per_axis (core, setting-off assertions).
  • verify_and_authorize and authorize_holder keep their signatures and run with the setting off; the node's own authorization paths pass the configured setting through *_with_setting entry points.

Additional refusals (setting on)

Request shape Limit Status and text Tests
{"action":"query","sql":"PRAGMA table_info(Notes)"} (any PRAGMA schema report: table_info, table_xinfo, table_list, database_list, index_list, index_info, foreign_key_list; admin ability), and SELECT name FROM pragma_table_info('Notes') even when pragma_table_info is listed tables or columns 403 sql_caveat_violation: table route schema_reports_and_export_are_refused_under_a_table_or_column_limit; authorizer schema_reports_are_refused_under_a_table_or_column_restriction, case A17b
PRAGMA integrity_check, PRAGMA quick_check, PRAGMA foreign_key_check (with or without a table argument; admin ability) tables or columns 403 sql_caveat_violation: table the same route test; authorizer cases P1 to P3, P7
PRAGMA user_version = <n>, PRAGMA application_id = <n> (admin ability) tables or columns (and readOnly, case E2) 403 sql_caveat_violation: write the same route test; authorizer cases P4 to P6
{"action":"export"} tables 403 sql_caveat_violation: table route schema_reports_and_export_are_refused_under_a_table_or_column_limit, delegated_sql_caveats_are_admitted_and_enforced_when_the_setting_is_on
{"action":"export"} columns without tables 403 sql_caveat_violation: column route schema_reports_and_export_are_refused_under_a_table_or_column_limit
DDL on a table (or view) the limit does not list: ALTER TABLE t2 ADD COLUMN z, CREATE TABLE z (a), CREATE INDEX .. ON t2 (..), DROP INDEX, DROP TABLE, CREATE/DROP VIEW (schema-conferring ability; sqlite_master listed) tables 403 sql_caveat_violation: table route ddl_needs_the_table_it_names_to_be_listed; authorizer cases M1 to M8b
any request whose chain has a hop granting the invoked ability whose caveat cannot be read (the hop has a readable row for the same ability beside it, so it covers the invocation), or a chain that holds both a constrained-statements profile and the delegated form any 403 sql_caveat_violation: chain route the_stored_chain_narrows_the_request_even_when_a_hop_is_wider (unreadable hop); core an_unparseable_hop_anywhere_fails_the_chain, a_profile_on_the_chain_is_reported_beside_a_form (evaluation)

Under readOnly alone the PRAGMA schema reports, the checks and the export are allowed; a PRAGMA that sets a value is a write. An unreadable caveat on the only row that grants the ability does not cover the invocation: 401, the unauthorized-action mapping.

Authorizer action audit (rusqlite 0.32 AuthAction)

Test every_authorizer_action_is_judged_against_the_table_it_names_or_involves_none runs delegated_denial for every variant under a tables limit with an unlisted table, with the listed table, and under readOnly; a write statement under readOnly is refused at the first callback of every variant. The arms:

Action Table named tables, unlisted tables, listed readOnly
Read table table none none
Insert, Delete table table none (under columns: column) write
Update table table none (column checked) write
CreateTable, CreateTempTable, DropTable, DropTempTable table table none write
AlterTable, Analyze table table none write
CreateIndex, CreateTempIndex, DropIndex, DropTempIndex table of the index table none write
CreateTrigger, CreateTempTrigger, DropTrigger, DropTempTrigger table of the trigger table none write
CreateView, CreateTempView, DropView, DropTempView the view table none write
CreateVtable, DropVtable table table none write
Pragma schema report (table_info(t) and the other six) none (lists tables) table table none
Pragma integrity_check, quick_check, foreign_key_check none (lists tables) table table none
Pragma user_version = n, application_id = n none (database header) write write write
Pragma user_version (no value) none none none none
Reindex an index, not a table; the index's table is judged by the actions that build it none none write
Select, Function, Recursive none none none none
Transaction, Savepoint, Attach, Detach none; refused by the abilities for every holder none none none
Unknown and any variant added later unknown; refused by the abilities for every holder table table none

REINDEX <table> reports no action at all on the bundled SQLite.

Vectors (VECTORS-A) and what they resolve to

Vectors Result
V1 [] cannot occur on the wire: the capability parser refuses an empty list; an empty caveat map is unrestricted
V2 [{}] stored as {}: unrestricted
V3 to V9, V11 accepted (setting on)
V10 profile, mode and nested forms accepted
V12 [{},{}] stored as {"0":{},"1":{}}: accepted (setting on), unrestricted; a parent of this shape imposes nothing on a child; refused with the setting off
R1 to R8, R10 unsupported-caveat: <ability> caveat <index> <fixed reason> (401); the reason carries no caveat content
R9 duckdb empty caveats only
S1 to S8 as listed; S9 owner-issued grants unconstrained
E1 to E4, E6 as listed; E4 is exercised with a stored chain whose middle hop is wider than its parent; E6 with a real invocation fact (route an_invocation_fact_only_narrows_the_chain_caveat)
E5 see Additional refusals: 403 sql_caveat_violation: chain beside a readable row; 401 on the only row
E7 setting off: see "With the setting off"
Name cap and charset 1..=128 bytes per element, 1..=256 elements; no ., no control characters, no leading or trailing whitespace; non-ASCII names match exactly (only_ascii_letters_fold, non_ascii_names_match_only_exactly)

Authorizer case list (SQLB-TESTS, sections A to E) on the bundled SQLite

Every row of A to E is run as a table-driven unit test against create_authorizer_scoped / create_authorizer_tracked (rusqlite 0.32 bundled SQLite), reading the denying arm from the tracker.

Case List expectation Observed Resolution
(none) No row's allow/deny or arm differs from the list.

Rows whose authorizer event stream differs while the arm is the same: A17 (pragma_table_info('Notes') reports a read of the table-valued function pragma_table_info plus a PRAGMA table_info, not a read of sqlite_master); arm table as listed. A14 reports the column ROWID, C15 reports only INSERT, as listed. Rows added: A17b, C15 with readOnly (write), M1 to M9 (DDL, each with the allow row when the table is listed), P1 to P7 (PRAGMA checks and header values), a read-tier and a non-delegated authority row, precedence, and PRAGMA schema reports under tables / columns / readOnly.

Files

  • tinycloud-core/src/sql/delegated_caveats.rs: grammar, algebra, chain evaluation (and its unit tests).
  • tinycloud-core/src/models/delegation.rs, invocation.rs, db.rs: admission and invocation reading behind the setting, containment through caveats_contain_child.
  • tinycloud-core/src/sql/authority.rs, authorizer.rs, caveats.rs, database.rs, parser.rs, types.rs: delegated-rules flag and arm tracker on SqlAuthority, case-insensitive matching, SqlError::CaveatViolation.
  • tinycloud-node-server/src/routes/mod.rs, config.rs, lib.rs, tinycloud.toml: setting, /info, effective caveat in handle_sql_invoke, 403 mapping.

Tests

Authorizer table (A to E, M, P) and the action audit; service-level refusals (whole request, zero repository calls, no name or SQL text, ability first, read-tier); grammar and algebra vectors; stored-chain intersection including a hop wider than its parent and unparseable hops; /delegate and /invoke route tests with the setting on and off (exact refusal text when off), a real invocation fact, a named statement under a chain caveat, DDL under tables; /info on and off; list normalization of [{}], [{},{}] and [].

Details: TC-1791

tc1791a builder added 7 commits October 23, 2026 17:31
…on (behind a setting)

Adds [global.sql] delegated_caveats_enforced (off by default). When on, a
tinycloud.sql/* delegation may carry tables / columns / readOnly caveats,
each hop must be at least as narrow as its parent (ASCII-case-insensitive),
and /info lists sql-delegated-caveats-v1. When off, such a delegation is
refused as an unsupported caveat as before.

Details: TC-1791
…name the arm

With the delegated-caveat rules on a request, the authorizer decides the
abilities first, then the caveats: names compare ASCII-case-insensitively, a
read that names no column is allowed when the table is, INSERT and DELETE
are refused under columns, readOnly refuses writes, DDL and PRAGMA with a
value. The arm that refused (write, then table, then column) is recorded and
the request fails with 403 sql_caveat_violation: <arm>, whole.

Details: TC-1791
… chain

With the setting on, a SQL request is run under the intersection of the
delegated tables / columns / readOnly caveats over every stored delegation on
the chain the invocation cites: a hop without the ability contributes
nothing, readOnly is an OR, an unparseable hop fails the request with 403
sql_caveat_violation: chain. The invocation's sqlCaveats fact only narrows.
The one effective value is what sql_request_is_write, the transaction
decision and the authorizer see. A database export is refused under a
tables or columns limit.

Details: TC-1791
…document refusals

With the setting off, invocation validation and per-hop containment call the
code they called before; the setting is passed to them. Adds tests for stored
rows with the setting off and for the PRAGMA schema report and export
refusals, and lists the additional refusals in the docs page.

Details: TC-1791
…eader values under a limit

Under a tables limit every action that names a table (CREATE, ALTER, DROP of
a table, index, view or trigger, ANALYZE, virtual tables) needs that table
listed. PRAGMA integrity_check, quick_check and foreign_key_check are refused
under tables or columns (table arm); PRAGMA user_version and application_id
with a value are refused (write arm). Adds the per-action audit test, route
tests for an invocation fact, a named statement and DDL, non-ASCII name tests,
and neutral wording.

Details: TC-1791
@samgbafa
samgbafa marked this pull request as ready for review October 23, 2026 18:30
@samgbafa

Copy link
Copy Markdown
Contributor Author

APPROVE: see TC-1791 (reviewed exact head 714f10f)

@samgbafa
samgbafa merged commit 87bd7a9 into main Oct 23, 2026
25 checks passed
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.

1 participant