Repository navigation
sql: enforce delegated table, column and read-only caveats (SQLite) - #425
Merged
Merged
Conversation
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
marked this pull request as ready for review
October 23, 2026 18:30
Contributor
Author
|
APPROVE: see TC-1791 (reviewed exact head 714f10f) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
A
tinycloud.sql/*delegation can carrytables,columnsandreadOnlycaveats. 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(envTINYCLOUD_SQL__DELEGATED_CAVEATS_ENFORCED), default off and commented out intinycloud.toml. On:/infofeatureslistssql-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 (modekey, or nestedconstrained-statements) is read by the profile rules; or the delegated form: keys exactly a subset oftables,columns,readOnly.{}beside a form) are refused. A list of only empty entries restricts nothing.modetogether withtablesorcolumnsis refused..; 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 notk) and exact otherwise: no wildcards, notable.column.readOnlyis a boolean;falseequals absent.tables: []allows no table.Rules (setting on)
/delegate, the existingcaveats_contain_child, case folded on both sides): a child'stables/columnsare subsets of the parent's (absent parent axis = anything; a child that drops a restricted axis is refused);readOnlymay be turned on, not off. Refusal: 401child-caveats-not-subset-of-parent: <axis>. A grant issued by the space owner is unconstrained./invoke): effectivetables/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'ssqlCaveatsfact only narrows (readOnly: falsedoes not lift areadOnlychain); a request with only a fact, nothing on the chain, is judged by the same rules. The one value is passed tosql_request_is_write, the transaction decision and the authorizer, for raw and named statements alike. Invocations carry no delegations of their own.SqlAuthority): a column-less read (count(*)) is allowed when the table is; undercolumnsINSERT and DELETE are refused and UPDATE needs its columns (and the WHERE columns) listed;readOnlyrefuses 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_masterandpragma_*functions must be listed; a statement that creates, alters or drops a table, index, view or trigger needs that table or view listed.sql_caveat_violation: write,tableorcolumn; 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:/delegateadmission, 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),/infofeatures, and configuration (a missing[global.sql]section is the default) run the code they run without this feature.SqlError::CaveatViolationcannot occur./delegatewith a delegated-form caveat (tables,columns,readOnlyentry, or two empty entries): 401unsupported-caveat: <ability> caveat <index> is not a constrained-statements profile, nothing stored. Testsdelegated_sql_caveats_are_refused_when_the_setting_is_off(route, exact body) andwith_the_setting_off_only_empty_caveats_and_profiles_are_accepted(core)./info: thefeatureslist has no new string; testsql_delegated_caveats_feature_is_advertised_only_behind_its_settingasserts the on list is the off list plus the one string.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 401invocation-caveats-not-subset-of-chain, and nothing runs; a row of this kind beside a good row for the same ability is ignored. Testsstored_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_onanddelegated_form_hops_are_contained_per_axis(core, setting-off assertions).verify_and_authorizeandauthorize_holderkeep their signatures and run with the setting off; the node's own authorization paths pass the configured setting through*_with_settingentry points.Additional refusals (setting on)
{"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), andSELECT name FROM pragma_table_info('Notes')even whenpragma_table_infois listedtablesorcolumnssql_caveat_violation: tableschema_reports_and_export_are_refused_under_a_table_or_column_limit; authorizerschema_reports_are_refused_under_a_table_or_column_restriction, case A17bPRAGMA integrity_check,PRAGMA quick_check,PRAGMA foreign_key_check(with or without a table argument; admin ability)tablesorcolumnssql_caveat_violation: tablePRAGMA user_version = <n>,PRAGMA application_id = <n>(admin ability)tablesorcolumns(andreadOnly, case E2)sql_caveat_violation: write{"action":"export"}tablessql_caveat_violation: tableschema_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"}columnswithouttablessql_caveat_violation: columnschema_reports_and_export_are_refused_under_a_table_or_column_limitALTER TABLE t2 ADD COLUMN z,CREATE TABLE z (a),CREATE INDEX .. ON t2 (..),DROP INDEX,DROP TABLE,CREATE/DROP VIEW(schema-conferring ability;sqlite_masterlisted)tablessql_caveat_violation: tableddl_needs_the_table_it_names_to_be_listed; authorizer cases M1 to M8bsql_caveat_violation: chainthe_stored_chain_narrows_the_request_even_when_a_hop_is_wider(unreadable hop); corean_unparseable_hop_anywhere_fails_the_chain,a_profile_on_the_chain_is_reported_beside_a_form(evaluation)Under
readOnlyalone 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_nonerunsdelegated_denialfor every variant under atableslimit with an unlisted table, with the listed table, and underreadOnly; a write statement underreadOnlyis refused at the first callback of every variant. The arms:tables, unlistedtables, listedreadOnlyReadInsert,Deletecolumns: column)UpdateCreateTable,CreateTempTable,DropTable,DropTempTableAlterTable,AnalyzeCreateIndex,CreateTempIndex,DropIndex,DropTempIndexCreateTrigger,CreateTempTrigger,DropTrigger,DropTempTriggerCreateView,CreateTempView,DropView,DropTempViewCreateVtable,DropVtablePragmaschema report (table_info(t)and the other six)Pragmaintegrity_check,quick_check,foreign_key_checkPragmauser_version = n,application_id = nPragmauser_version(no value)ReindexSelect,Function,RecursiveTransaction,Savepoint,Attach,DetachUnknownand any variant added laterREINDEX <table>reports no action at all on the bundled SQLite.Vectors (
VECTORS-A) and what they resolve to[][{}]{}: unrestrictedmodeand nested forms[{},{}]{"0":{},"1":{}}: accepted (setting on), unrestricted; a parent of this shape imposes nothing on a child; refused with the setting offunsupported-caveat: <ability> caveat <index> <fixed reason>(401); the reason carries no caveat contentan_invocation_fact_only_narrows_the_chain_caveat)sql_caveat_violation: chainbeside a readable row; 401 on the only row., 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 SQLiteEvery 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.Rows whose authorizer event stream differs while the arm is the same: A17 (
pragma_table_info('Notes')reports a read of the table-valued functionpragma_table_infoplus a PRAGMAtable_info, not a read ofsqlite_master); armtableas listed. A14 reports the columnROWID, C15 reports only INSERT, as listed. Rows added: A17b, C15 withreadOnly(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 undertables/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 throughcaveats_contain_child.tinycloud-core/src/sql/authority.rs,authorizer.rs,caveats.rs,database.rs,parser.rs,types.rs: delegated-rules flag and arm tracker onSqlAuthority, case-insensitive matching,SqlError::CaveatViolation.tinycloud-node-server/src/routes/mod.rs,config.rs,lib.rs,tinycloud.toml: setting,/info, effective caveat inhandle_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;
/delegateand/invokeroute tests with the setting on and off (exact refusal text when off), a real invocation fact, a named statement under a chain caveat, DDL undertables;/infoon and off; list normalization of[{}],[{},{}]and[].Details: TC-1791