diff --git a/CHANGELOG.md b/CHANGELOG.md index 91b2c21b..b71cf765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Encryption: key route errors start with a stable code: `network_not_found:`, `unauthorized:`, `key_not_found:` or `key_path_invalid:`. Request bodies that fail to parse on the key routes return 400 with the text `request body is malformed: `. Key paths are read from the request as sent, so a path with encoded or relative segments is refused rather than normalized. Network ids are accepted only in canonical form. Details: TC-1777. - Encryption: named-key decrypt is served on `POST /invoke` (a key capability plus `keyPath` in the body); the direct network decrypt route refuses key resources and `keyPath`. A root-key unwrap failure on the direct route returns 403 `key_unwrap_failed`. Decrypt requests whose receiver key is not a valid 32-byte X25519 public key, or is a low-order point, are refused with 400 and the code `receiver_key_invalid`. Details: TC-1777. - Configuration: adds `[encryption_network] named_keys_v1`, off by default. Details: TC-1777. +- SQL: a `tinycloud.sql/*` delegation can carry `tables`, `columns` and `readOnly` caveats (names match ASCII-case-insensitively; each hop must be at least as narrow as its parent). A request is judged by the intersection of these caveats over every stored delegation on the chain it cites, and a request they do not allow fails with 403 `sql_caveat_violation: write`, `table` or `column`, the whole request refused. Under a `tables` or `columns` limit the PRAGMA schema reports and checks (`sql_caveat_violation: table`), the PRAGMAs that set `user_version` or `application_id` (`sql_caveat_violation: write`) and the database export are refused; DDL on a table needs that table listed. A chain whose caveat cannot be read fails with `sql_caveat_violation: chain`. Behind `[global.sql] delegated_caveats_enforced` (env `TINYCLOUD_SQL__DELEGATED_CAVEATS_ENFORCED`), off by default; when on, `GET /info` lists `sql-delegated-caveats-v1`. With the setting off, these delegations are refused as an unsupported caveat. DuckDB takes empty caveats only. Docs: added `docs/sql-delegated-caveats.md`. Details: TC-1791. ## [1.21.9] - 2026-10-23 diff --git a/docs/sql-delegated-caveats.md b/docs/sql-delegated-caveats.md new file mode 100644 index 00000000..b592f953 --- /dev/null +++ b/docs/sql-delegated-caveats.md @@ -0,0 +1,58 @@ +# Delegated SQL caveats + +A `tinycloud.sql/*` delegation can limit the tables and columns a holder may touch, and make the grant read-only. The node applies the limit to every SQL request that cites the delegation, however many hops sit between the owner and the holder. + +The feature is behind `[global.sql] delegated_caveats_enforced` (env `TINYCLOUD_SQL__DELEGATED_CAVEATS_ENFORCED`), off by default. When on, `GET /info` lists `sql-delegated-caveats-v1` in `features`. When off, the form below is refused like any other unsupported caveat (`unsupported-caveat: ...`) and `/info` does not list the feature. DuckDB: `tinycloud.duckdb/*` takes empty caveats only. + +## Grammar + +The caveats of one ability are a list of objects. One entry may be: + +| Entry | Meaning | +|---|---| +| `{}` | no restriction | +| `{"tables": [...], "columns": [...], "readOnly": true}` | the delegated form; keys are any subset of the three | +| a constrained-statements profile (`mode` key, or nested `constrained-statements`) | the request is bound to the profile's statements | + +- `tables` and `columns` are lists of 1 to 256 names. A name is 1 to 128 bytes, has no `.` (so no `table.column`), no control character, and no leading or trailing whitespace. There are no wildcards: a name matches only itself. +- Names compare ASCII-case-insensitively, on both sides, everywhere (admission, containment, execution). Only the ASCII letters fold: the Kelvin sign (U+212A) is a different name from `k`. +- `readOnly` is a boolean. `false` is the same as absent. An absent key is no restriction on that axis; `"tables": []` is a restriction that allows no table. +- A list with a delegated entry has exactly that one entry. Entries of a list are alternatives, so a second entry, even an empty one, is refused. A list of only empty entries (`[{}]`, `[{}, {}]`) restricts nothing. An entry that has `mode` and also `tables` or `columns` is refused. +- Anything else (unknown key, wrong type, empty or over-long name, too many names) is refused at `/delegate` with `unsupported-caveat: caveat `; the reason never repeats a name from the request. + +## Chains + +At `/delegate`, a child must be at least as narrow as the parent grant it relies on: its `tables` and `columns` are subsets of the parent's (an absent parent axis allows anything; a child that drops a restricted axis is refused), and `readOnly` may be turned on but not off. A widening hop is refused with `child-caveats-not-subset-of-parent`. A grant issued by the space owner is unconstrained. + +At `/invoke`, the node computes the effective caveat from the stored delegations on the chain the invocation cites by CID, without relying on how each was admitted: `tables` and `columns` are the intersection over every hop that grants the invoked ability on the invoked resource, and `readOnly` is true if any hop sets it. A hop without the ability contributes nothing. A hop wider than its parent, for example one stored without the per-hop check, can only be narrowed by the intersection. If a matching hop's caveat cannot be read, the request is refused with 403 `sql_caveat_violation: chain`. The invocation's own `sqlCaveats` fact can narrow the result further and never widen it (`readOnly: false` does not lift a `readOnly` chain). A request that carries only an invocation fact, with no delegated form on its chain, is judged by the same rules: names fold, and `count(*)` is allowed under `columns`. Invocations carry no delegations of their own; a cited CID with no stored delegation is refused. + +## Refusals + +A request the effective caveat does not allow fails with 403 and the text `sql_caveat_violation: write`, `sql_caveat_violation: table` or `sql_caveat_violation: column`. The text names the arm and nothing else: no SQL, table or column name. When several arms apply, `write` wins over `table` over `column`. The abilities are checked first: a `read` grant that writes is refused by the ability check. A request with several statements is refused whole: nothing it did is kept. A named statement (`executeStatement`) is judged like any other statement. + +## Additional refusals + +Four kinds of refusal are not decided by one statement's table, column or write. All are 403. + +| Request | Limit | Text | +|---|---|---| +| `{"action":"query","sql":"PRAGMA table_info()"}` (any PRAGMA schema report: `table_info`, `table_xinfo`, `table_list`, `database_list`, `index_list`, `index_info`, `foreign_key_list`; admin ability), and `SELECT ... FROM pragma_table_info('
')` even when the function is listed | `tables` or `columns` | `sql_caveat_violation: table` | +| `{"action":"query","sql":"PRAGMA integrity_check"}`, `quick_check`, `foreign_key_check` (with or without a table argument; admin ability) | `tables` or `columns` | `sql_caveat_violation: table` | +| `{"action":"query","sql":"PRAGMA user_version = "}`, `application_id = ` (admin ability) | `tables` or `columns` (and `readOnly`) | `sql_caveat_violation: write` | +| `{"action":"export"}` | `tables` | `sql_caveat_violation: table` | +| `{"action":"export"}` | `columns` without `tables` | `sql_caveat_violation: column` | +| any request whose chain has an unreadable caveat on a hop that grants the invoked ability, or mixes a constrained-statements profile with the delegated form | any | `sql_caveat_violation: chain` | + +Under `readOnly` alone, the PRAGMA schema reports, the checks and the export are allowed; a PRAGMA that sets a value is a write. + +## What each limit does + +- **`readOnly`** refuses every write and schema statement, a `PRAGMA` that sets a value (`PRAGMA user_version = 5`), and any statement the parser does not classify as read-only. Such a statement is refused at its first callback, whatever the action. +- **`tables`** lists every table a statement reads, writes or changes, including the tables reached through a view, a trigger or a foreign key. Writing to a table with a trigger or a foreign key needs the tables they touch listed too. Reading a view needs the view and its underlying tables. A statement that creates, alters or drops a table, an index, a view or a trigger needs that table or view listed, as well as `sqlite_master` (`sqlite_temp_master` for a temporary object) for the schema bookkeeping. `sqlite_master` and the `pragma_*` table functions are refused unless listed (see Additional refusals for the PRAGMA they report). +- **`columns`** lists every column a statement reads, plus the columns an `UPDATE` sets; `SELECT *` needs every column of the table listed. `INSERT` and `DELETE` name no column and are refused. A read that names no column, such as `count(*)` or `SELECT 1 FROM t`, is allowed when the table is allowed; it shows the row count and nothing else. A table without an integer primary key reads as the column `rowid`, which must be listed to be read. +- `REPLACE INTO` is judged as the `INSERT` it reports; under `columns` it is refused, under `readOnly` it is a write. +- The grant is checked against the names as created, folded: `SELECT body FROM NOTES` against `tables: ["notes"]` is allowed. + +## Settings + +`[global.sql] delegated_caveats_enforced = true` turns on admission, enforcement and the `/info` feature together. With it off the delegated form is refused at `/delegate` with `unsupported-caveat: caveat is not a constrained-statements profile`; a stored delegation that carries a non-profile caveat on a `tinycloud.sql/*` ability, as one stored while the setting was on does, does not cover an invocation (`invocation-caveats-not-subset-of-chain`), and nothing runs under it; `/info` does not list the feature. diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 0cdc69d9..9912837a 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -165,6 +165,7 @@ pub struct SpaceDatabase { secrets: S, encryption: Option, sql_sizes: SqlSizes, + delegated_sql_caveats: bool, revocation_chain_locks: ChainLockRegistry, kv_object_locks: KvObjectLockRegistry, space_sequence_locks: SpaceSequenceLockRegistry, @@ -423,6 +424,7 @@ where secrets, encryption, sql_sizes: SqlSizes::default(), + delegated_sql_caveats: false, revocation_chain_locks: Arc::new(tokio::sync::Mutex::new(HashMap::new())), kv_object_locks: Arc::new(tokio::sync::Mutex::new(HashMap::new())), space_sequence_locks: Arc::new(tokio::sync::Mutex::new(HashMap::new())), @@ -434,6 +436,14 @@ where }) } + /// Admit the delegated `tables` / `columns` / `readOnly` caveat form on + /// `tinycloud.sql/*` abilities (TC-1791). Off by default: such a + /// delegation is refused as an unsupported caveat. + pub fn with_delegated_sql_caveats(mut self, enabled: bool) -> Self { + self.delegated_sql_caveats = enabled; + self + } + pub fn with_sql_sizes(mut self, sql_sizes: SqlSizes) -> Self { self.sql_sizes = sql_sizes; self @@ -653,7 +663,13 @@ where invocation: &crate::util::InvocationInfo, now: OffsetDateTime, ) -> Result<(), crate::models::invocation::Error> { - invocation::verify_and_authorize(&self.conn, invocation, now).await + invocation::verify_and_authorize_with_setting( + &self.conn, + invocation, + now, + self.delegated_sql_caveats, + ) + .await } /// [`Self::authorize_invocation`] for an invocation whose signature and @@ -665,7 +681,14 @@ where invocation: &crate::AdmittedInvocation, now: OffsetDateTime, ) -> Result<(), crate::models::invocation::Error> { - invocation::authorize_admitted(&self.conn, &invocation.invocation().0, now, None).await + invocation::authorize_admitted( + &self.conn, + &invocation.invocation().0, + now, + None, + self.delegated_sql_caveats, + ) + .await } /// Check the persisted revocation state for cited delegations and every @@ -738,9 +761,14 @@ where query: &DelegationQuery, ) -> Result { let now = OffsetDateTime::now_utc(); - invocation::verify_and_authorize(&self.conn, invocation, now) - .await - .map_err(|_| AccountDelegationQueryError::Unauthorized)?; + invocation::verify_and_authorize_with_setting( + &self.conn, + invocation, + now, + self.delegated_sql_caveats, + ) + .await + .map_err(|_| AccountDelegationQueryError::Unauthorized)?; let principal = account_query_principal(&self.conn, invocation) .await? .ok_or(AccountDelegationQueryError::Unauthorized)?; @@ -1652,6 +1680,7 @@ where self.encryption.as_ref(), None, &[], + self.delegated_sql_caveats, ) .await?; @@ -1942,6 +1971,7 @@ where self.encryption.as_ref(), None, &[], + self.delegated_sql_caveats, ) .await } @@ -2416,6 +2446,7 @@ where Some(&auth_graph), // Taken on this `tx` right after BEGIN, above. &sequence_spaces, + self.delegated_sql_caveats, ) .await?; @@ -2594,17 +2625,22 @@ where // signed-time validity here rather than verifying the signature a // second time. match mode { - InvokeMode::Admitted => { - invocation::authorize_admitted(&self.conn, &invocation.0, now, sync_graph.as_ref()) - .await - .map_err(TxError::::from)? - } + InvokeMode::Admitted => invocation::authorize_admitted( + &self.conn, + &invocation.0, + now, + sync_graph.as_ref(), + self.delegated_sql_caveats, + ) + .await + .map_err(TxError::::from)?, InvokeMode::Public | InvokeMode::Internal | InvokeMode::MeetingPublication => { invocation::verify_and_authorize_with_graph( &self.conn, &invocation.0, now, sync_graph.as_ref(), + self.delegated_sql_caveats, ) .await .map_err(TxError::::from)? @@ -3289,6 +3325,7 @@ async fn event_spaces<'a, C: ConnectionTrait>( /// `sequence_locks_held` lists the spaces whose sequence lock the caller has /// already taken on `db` (this same transaction) with /// `kv_sync::lock_space_sequences`; `&[]` when it has taken none. +#[allow(clippy::too_many_arguments)] pub(crate) async fn transact( db: &C, store_setup: &S, @@ -3297,6 +3334,7 @@ pub(crate) async fn transact( encryption: Option<&ColumnEncryption>, auth_graph: Option<&crate::auth_graph::AuthGraphSnapshot>, sequence_locks_held: &[SpaceId], + delegated_sql_caveats: bool, ) -> Result> { // for each event, get the hash and the relevent space(s) let event_hashes = events @@ -3321,7 +3359,7 @@ pub(crate) async fn transact( let mut registered: HashMap = HashMap::new(); for (hash, event) in &event_hashes { if let Event::Delegation(d) = event { - let reg = delegation::process(db, d, encryption).await?; + let reg = delegation::process_with(db, d, encryption, delegated_sql_caveats).await?; registered.insert(*hash, reg); } } @@ -3649,6 +3687,7 @@ pub(crate) async fn transact( .collect(), encryption, auth_graph, + delegated_sql_caveats, ) .await?; } @@ -3684,6 +3723,7 @@ pub(crate) async fn transact( .collect(), encryption, auth_graph, + delegated_sql_caveats, ) .await?; } @@ -3739,14 +3779,29 @@ pub(crate) async fn transact( } } Event::Invocation(i, _ops) => { - invocation::process(db, *i, Vec::new(), encryption, auth_graph).await?; + invocation::process( + db, + *i, + Vec::new(), + encryption, + auth_graph, + delegated_sql_caveats, + ) + .await?; } Event::InternalInvocation(i, _ops) => { invocation::process_internal(db, *i, Vec::new(), encryption).await?; } Event::AdmittedInvocation(i, _ops) => { - invocation::process_admitted(db, *i, Vec::new(), encryption, auth_graph) - .await?; + invocation::process_admitted( + db, + *i, + Vec::new(), + encryption, + auth_graph, + delegated_sql_caveats, + ) + .await?; } Event::Revocation(r) => { revocation::process(db, *r).await?; diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index 95aa6fa0..f23ee7bd 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -194,16 +194,28 @@ pub enum DelegationError { /// Returns `New` when the exact serialization was inserted (and actors, /// abilities, and parents written). Returns `Existing` when the hash was /// already present; no dependent rows are duplicated in that case. +#[cfg(test)] pub(crate) async fn process( db: &C, delegation: &Delegation, encryption: Option<&ColumnEncryption>, +) -> Result { + process_with(db, delegation, encryption, false).await +} + +/// As `process`; with `delegated_sql_caveats` a SQL capability may carry the +/// delegated `tables` / `columns` / `readOnly` caveat form (TC-1791). +pub(crate) async fn process_with( + db: &C, + delegation: &Delegation, + encryption: Option<&ColumnEncryption>, + delegated_sql_caveats: bool, ) -> Result { verify(&delegation.0.delegation).await?; for space in delegation.0.spaces() { crate::request_telemetry::observe_space(space); } - validate(db, &delegation.0).await?; + validate_with(db, &delegation.0, delegated_sql_caveats).await?; save(db, delegation.0.clone(), delegation.1.clone(), encryption).await } @@ -272,22 +284,71 @@ pub(crate) fn unsupported_sql_caveat( }) } -fn check_sql_caveats_supported(cap: &util::Capability) -> Result<(), Error> { - match unsupported_sql_caveat(cap.ability.as_ref().as_ref(), &cap.caveats) { +/// Reason a SQL or DuckDB capability's caveats are refused at admission. With +/// `delegated_sql_caveats` a SQL capability may also carry the delegated +/// `tables` / `columns` / `readOnly` form; DuckDB takes empty caveats only. +pub(crate) fn unsupported_sql_caveat_at_ingress( + ability: &str, + caveats: &crate::types::Caveats, + delegated_sql_caveats: bool, +) -> Option { + use crate::sql::delegated_caveats::{classify, Reading}; + if delegated_sql_caveats && ability.starts_with("tinycloud.sql/") { + return classify(caveats, Reading::Ingress) + .err() + .map(|refusal| refusal.reason(ability)); + } + unsupported_sql_caveat(ability, caveats) +} + +/// As [`unsupported_sql_caveat`], for a row already stored. With +/// `delegated_sql_caveats` the delegated form is read as well. +pub(crate) fn unsupported_stored_sql_caveat( + ability: &str, + caveats: &crate::types::Caveats, + delegated_sql_caveats: bool, +) -> Option { + use crate::sql::delegated_caveats::{classify, Reading}; + if delegated_sql_caveats && ability.starts_with("tinycloud.sql/") { + return classify(caveats, Reading::Stored) + .err() + .map(|refusal| refusal.reason(ability)); + } + unsupported_sql_caveat(ability, caveats) +} + +fn check_sql_caveats_supported( + cap: &util::Capability, + delegated_sql_caveats: bool, +) -> Result<(), Error> { + match unsupported_sql_caveat_at_ingress( + cap.ability.as_ref().as_ref(), + &cap.caveats, + delegated_sql_caveats, + ) { Some(reason) => Err(DelegationError::UnsupportedCaveat(reason).into()), None => Ok(()), } } // verify parenthood and authorization +#[cfg(test)] async fn validate( db: &C, delegation: &util::DelegationInfo, +) -> Result<(), Error> { + validate_with(db, delegation, false).await +} + +async fn validate_with( + db: &C, + delegation: &util::DelegationInfo, + delegated_sql_caveats: bool, ) -> Result<(), Error> { // TC-1791: SQL and DuckDB caveats are checked for every delegation, // before the root-authority filter. for cap in &delegation.capabilities { - check_sql_caveats_supported(cap)?; + check_sql_caveats_supported(cap, delegated_sql_caveats)?; } // Key deletion is never delegated, including by the key's owner. if delegation.capabilities.iter().any(|cap| { @@ -527,7 +588,12 @@ async fn validate( let mut last_reason: Option = None; let mut authorized = false; for pc in candidates { - match caveats_contain_child(&pc.caveats, &c.caveats) { + match caveats_contain_child( + c.ability.as_ref().as_ref(), + &pc.caveats, + &c.caveats, + delegated_sql_caveats, + ) { Ok(()) => { authorized = true; break; @@ -775,13 +841,50 @@ fn validate_policy_descendant( /// caveat (delegating to `sql_caveat::contains` for narrow/identical chain /// shapes) and otherwise requires structural equality so a child cannot /// silently drop or replace caveats the parent carried. -fn caveats_contain_child(parent: &Caveats, child: &Caveats) -> Result<(), String> { +fn caveats_contain_child( + ability: &str, + parent: &Caveats, + child: &Caveats, + delegated_sql_caveats: bool, +) -> Result<(), String> { if let Some(contained) = crate::policy_capability::selector_caveats_contain(&parent.0, &child.0) { return contained .then_some(()) .ok_or_else(|| "selector-caveat-not-contained".to_string()); } + // TC-1791: delegated tables / columns / readOnly caveats on SQL + // abilities. The parent decides which axes the child may not widen. + if delegated_sql_caveats && ability.starts_with("tinycloud.sql/") { + use crate::sql::delegated_caveats::{classify, AbilityCaveats, DelegatedCaveat, Reading}; + match ( + classify(parent, Reading::Stored), + classify(child, Reading::Stored), + ) { + (Ok(AbilityCaveats::Form(p)), Ok(AbilityCaveats::Form(c))) => { + return p.contains(&c).map_err(str::to_string); + } + (Ok(AbilityCaveats::Form(p)), Ok(AbilityCaveats::Unrestricted)) => { + return p + .contains(&DelegatedCaveat::default()) + .map_err(str::to_string); + } + (Ok(AbilityCaveats::Form(_)), Ok(AbilityCaveats::Profile)) => { + return Err("sql-caveats-mixed".to_string()); + } + // A parent of only empty entries imposes nothing, whatever shape + // the empty entries take. + (Ok(AbilityCaveats::Unrestricted), Ok(_)) if !parent.0.is_empty() => { + return caveats_contain_child( + "", + &Caveats::default(), + child, + delegated_sql_caveats, + ); + } + _ => {} + } + } let parent_sql = extract_sql_caveat(parent); let child_sql = extract_sql_caveat(child); @@ -1867,4 +1970,84 @@ mod tests { "terminal parent accepted for redelegation" ); } + + fn wire(list: serde_json::Value) -> Caveats { + let entries = list.as_array().expect("a list"); + if entries.len() == 1 && entries[0] == serde_json::json!({}) { + return Caveats::default(); + } + Caveats( + entries + .iter() + .enumerate() + .map(|(index, entry)| (index.to_string(), entry.clone())) + .collect(), + ) + } + + /// TC-1791: the per-hop containment of the delegated form goes through the + /// same `caveats_contain_child` as every other caveat (S1 to S8). + #[test] + fn delegated_form_hops_are_contained_per_axis() { + use serde_json::json; + const SQL: &str = "tinycloud.sql/read"; + let ok = |parent: serde_json::Value, child: serde_json::Value| { + caveats_contain_child(SQL, &wire(parent), &wire(child), true) + }; + assert!(ok( + json!([{"tables": ["Notes", "t2"]}]), + json!([{"tables": ["Notes"]}]) + ) + .is_ok()); // S1 + assert!(ok( + json!([{"tables": ["Notes"]}]), + json!([{"tables": ["Notes", "plain"]}]) + ) + .is_err()); // S2 + assert!(ok(json!([{}]), json!([{"tables": ["Notes"]}])).is_ok()); // S3 + assert!(ok(json!([{"readOnly": true}]), json!([{"readOnly": false}])).is_err()); // S4 + assert!(ok(json!([{"readOnly": true}]), json!([{}])).is_err()); // S4 + assert!(ok(json!([{}]), json!([{"readOnly": true}])).is_ok()); // S4 + assert!(ok( + json!([{"columns": ["id"]}]), + json!([{"columns": ["id", "body"]}]) + ) + .is_err()); // S5 + assert!(ok(json!([{"columns": ["id"]}]), json!([{"columns": ["id"]}])).is_ok()); // S5 + assert!(ok( + json!([{"tables": ["notes"]}]), + json!([{"tables": ["NOTES"]}]) + ) + .is_ok()); // S6 + assert!(ok(json!([{}]), json!([{}])).is_ok()); // S7 + assert!(ok(json!([{"tables": ["a"]}]), json!([{}])).is_err()); // S7 + assert!(ok(json!([{"tables": ["Notes"]}]), json!([{"readOnly": true}])).is_err()); // S8 + // A parent of only empty entries imposes nothing, in any shape. + assert!(ok(json!([{}, {}]), json!([{"tables": ["a"]}])).is_ok()); + assert!(ok(json!([{}, {}]), json!([{}])).is_ok()); + assert!(ok(json!([{}, {}]), json!([{}, {}])).is_ok()); + // A profile and the delegated form do not mix across a hop. + let profile = json!({"mode": "constrained-statements", "readOnly": true, + "statements": [{"name": "n", "sql": "SELECT 1"}]}); + assert!(ok(json!([{"tables": ["a"]}]), json!([profile.clone()])).is_err()); + assert!(ok(json!([profile]), json!([{"tables": ["a"]}])).is_err()); + // Other services: equal caveats, or an empty parent. + let kv = |parent: serde_json::Value, child: serde_json::Value| { + caveats_contain_child("tinycloud.kv/get", &wire(parent), &wire(child), true) + }; + assert!(kv(json!([{}, {}]), json!([{"tables": ["a"]}])).is_err()); + assert!(kv(json!([{}]), json!([{"tables": ["a"]}])).is_ok()); + + // Setting off: SQL caveats are contained as every other caveat is + // (equal caveats, or an empty parent). + let off = |parent: serde_json::Value, child: serde_json::Value| { + caveats_contain_child(SQL, &wire(parent), &wire(child), false) + }; + assert!(off(json!([{}, {}]), json!([{"tables": ["a"]}])).is_err()); + assert!(off(json!([{}, {}]), json!([{}])).is_err()); + assert!(off(json!([{}]), json!([{"tables": ["a"]}])).is_ok()); + assert!(off(json!([{"tables": ["a"]}]), json!([{"tables": ["a"]}])).is_ok()); + assert!(off(json!([{"tables": ["a"]}]), json!([{"tables": ["A"]}])).is_err()); + assert!(off(json!([{"tables": ["a"]}]), json!([{}])).is_err()); + } } diff --git a/tinycloud-core/src/models/invocation.rs b/tinycloud-core/src/models/invocation.rs index 0a8e4f99..99b51569 100644 --- a/tinycloud-core/src/models/invocation.rs +++ b/tinycloud-core/src/models/invocation.rs @@ -123,6 +123,7 @@ pub(crate) async fn process( ops: Vec, encryption: Option<&ColumnEncryption>, auth_graph: Option<&crate::auth_graph::AuthGraphSnapshot>, + delegated_sql_caveats: bool, ) -> Result { let (i, serialized) = (invocation.0, invocation.1); verify_invocation(&i.invocation).await?; @@ -131,7 +132,7 @@ pub(crate) async fn process( } let now = OffsetDateTime::now_utc(); - validate(db, &i, Some(now), auth_graph).await?; + validate(db, &i, Some(now), auth_graph, delegated_sql_caveats).await?; save(db, i, Some(now), serialized, ops, encryption).await } @@ -177,6 +178,7 @@ pub(crate) async fn process_admitted( ops: Vec, encryption: Option<&ColumnEncryption>, auth_graph: Option<&crate::auth_graph::AuthGraphSnapshot>, + delegated_sql_caveats: bool, ) -> Result { let (i, serialized) = (invocation.0, invocation.1); i.invocation @@ -188,7 +190,7 @@ pub(crate) async fn process_admitted( } let now = OffsetDateTime::now_utc(); - validate(db, &i, Some(now), auth_graph).await?; + validate(db, &i, Some(now), auth_graph, delegated_sql_caveats).await?; save(db, i, Some(now), serialized, ops, encryption).await } @@ -203,6 +205,7 @@ pub(crate) async fn authorize_admitted( invocation: &util::InvocationInfo, now: OffsetDateTime, auth_graph: Option<&crate::auth_graph::AuthGraphSnapshot>, + delegated_sql_caveats: bool, ) -> Result<(), Error> { invocation .invocation @@ -212,7 +215,7 @@ pub(crate) async fn authorize_admitted( for space in invocation.spaces() { crate::request_telemetry::observe_space(space); } - validate(db, invocation, Some(now), auth_graph).await + validate(db, invocation, Some(now), auth_graph, delegated_sql_caveats).await } pub async fn verify_invocation(invocation: &TinyCloudInvocation) -> Result<(), Error> { @@ -254,7 +257,17 @@ pub async fn verify_and_authorize( invocation: &util::InvocationInfo, now: OffsetDateTime, ) -> Result<(), Error> { - verify_and_authorize_with_graph(db, invocation, now, None).await + verify_and_authorize_with_setting(db, invocation, now, false).await +} + +/// [`verify_and_authorize`] with the delegated-SQL-caveat setting stated. +pub async fn verify_and_authorize_with_setting( + db: &C, + invocation: &util::InvocationInfo, + now: OffsetDateTime, + delegated_sql_caveats: bool, +) -> Result<(), Error> { + verify_and_authorize_with_graph(db, invocation, now, None, delegated_sql_caveats).await } /// [`verify_and_authorize`] against a caller-loaded authorization snapshot, @@ -265,12 +278,13 @@ pub(crate) async fn verify_and_authorize_with_graph( invocation: &util::InvocationInfo, now: OffsetDateTime, auth_graph: Option<&crate::auth_graph::AuthGraphSnapshot>, + delegated_sql_caveats: bool, ) -> Result<(), Error> { verify_invocation(&invocation.invocation).await?; for space in invocation.spaces() { crate::request_telemetry::observe_space(space); } - validate(db, invocation, Some(now), auth_graph).await + validate(db, invocation, Some(now), auth_graph, delegated_sql_caveats).await } // verify parenthood and authorization @@ -279,6 +293,7 @@ async fn validate( invocation: &util::InvocationInfo, time: Option, auth_graph: Option<&crate::auth_graph::AuthGraphSnapshot>, + delegated_sql_caveats: bool, ) -> Result<(), Error> { validate_capabilities( db, @@ -287,6 +302,7 @@ async fn validate( &invocation.parents, time, auth_graph, + delegated_sql_caveats, ) .await } @@ -304,7 +320,29 @@ pub async fn authorize_holder( parents: &[tinycloud_auth::ipld_core::cid::Cid], now: OffsetDateTime, ) -> Result<(), Error> { - validate_capabilities(db, capabilities, holder, parents, Some(now), None).await + validate_capabilities(db, capabilities, holder, parents, Some(now), None, false).await +} + +/// [`authorize_holder`] with the delegated-SQL-caveat setting stated. +#[cfg(test)] +pub(crate) async fn authorize_holder_with_setting( + db: &C, + holder: &str, + capabilities: &[util::Capability], + parents: &[tinycloud_auth::ipld_core::cid::Cid], + now: OffsetDateTime, + delegated_sql_caveats: bool, +) -> Result<(), Error> { + validate_capabilities( + db, + capabilities, + holder, + parents, + Some(now), + None, + delegated_sql_caveats, + ) + .await } /// Test oracle: `authorize_holder`'s checks against a caller-built snapshot. @@ -317,7 +355,16 @@ pub(crate) async fn authorize_holder_with_graph( now: OffsetDateTime, graph: &crate::auth_graph::AuthGraphSnapshot, ) -> Result<(), Error> { - validate_capabilities(db, capabilities, holder, parents, Some(now), Some(graph)).await + validate_capabilities( + db, + capabilities, + holder, + parents, + Some(now), + Some(graph), + false, + ) + .await } async fn validate_capabilities( @@ -327,6 +374,7 @@ async fn validate_capabilities( parents: &[tinycloud_auth::ipld_core::cid::Cid], time: Option, auth_graph: Option<&crate::auth_graph::AuthGraphSnapshot>, + delegated_sql_caveats: bool, ) -> Result<(), Error> { // A key prefix names a namespace; it cannot be invoked as a target. if capabilities.iter().any(|cap| { @@ -498,7 +546,12 @@ async fn validate_capabilities( let mut last_reason: Option = None; let mut authorized = false; for pc in candidates { - match caveats_contain_child(&pc.caveats, &c.caveats) { + match ability_caveats_contain_child( + c.ability.as_ref().as_ref(), + &pc.caveats, + &c.caveats, + delegated_sql_caveats, + ) { Ok(()) => { authorized = true; break; @@ -539,10 +592,17 @@ async fn validate_capabilities( ability.ability.as_ref().as_ref(), c.ability.as_ref().as_ref(), ) - && caveats_contain_child(&ability.caveats, &c.caveats).is_ok() - && crate::models::delegation::unsupported_sql_caveat( + && ability_caveats_contain_child( + c.ability.as_ref().as_ref(), + &ability.caveats, + &c.caveats, + delegated_sql_caveats, + ) + .is_ok() + && crate::models::delegation::unsupported_stored_sql_caveat( ability.ability.as_ref().as_ref(), &ability.caveats, + delegated_sql_caveats, ) .is_none() }; @@ -588,6 +648,39 @@ fn caveats_contain_child(parent: &Caveats, child: &Caveats) -> Result<(), String } } +/// TC-1791: [`caveats_contain_child`] for one invoked ability. A stored +/// delegated `tables` / `columns` / `readOnly` form on a SQL ability does not +/// refuse an invocation whose own capability carries no caveat: the restriction +/// is applied to the request from the whole chain when it executes. An +/// invocation capability that carries a delegated form of its own is refused by +/// the `unsupported_sql_caveat` check below. +fn ability_caveats_contain_child( + ability: &str, + parent: &Caveats, + child: &Caveats, + delegated_sql_caveats: bool, +) -> Result<(), String> { + if delegated_sql_caveats && ability.starts_with("tinycloud.sql/") { + use crate::sql::delegated_caveats::{classify, AbilityCaveats, Reading}; + match ( + classify(parent, Reading::Stored), + classify(child, Reading::Stored), + ) { + (Ok(AbilityCaveats::Form(_)), Ok(AbilityCaveats::Unrestricted)) => return Ok(()), + (Ok(AbilityCaveats::Form(_)), _) => { + return Err("invocation-caveats-not-subset-of-chain".to_string()); + } + // A parent of only empty entries imposes nothing, whatever shape + // the empty entries take. + (Ok(AbilityCaveats::Unrestricted), Ok(_)) if !parent.0.is_empty() => { + return caveats_contain_child(&Caveats::default(), child); + } + _ => {} + } + } + caveats_contain_child(parent, child) +} + /// Parses the constrained-statements profile carried by one capability's caveats. pub fn extract_sql_caveat( caveats: &Caveats, @@ -1677,6 +1770,107 @@ mod tests { .expect("a matching constrained-statements grant authorizes"); } + fn delegated_form_caveat(list: serde_json::Value) -> Caveats { + Caveats(BTreeMap::from([("0".to_string(), list)])) + } + + async fn holder_under( + db: &DatabaseConnection, + parent: tinycloud_auth::ipld_core::cid::Cid, + own: Caveats, + setting: bool, + ) -> Result<(), Error> { + let resource = "tinycloud:did:key:owner/sql/db"; + crate::models::invocation::authorize_holder_with_setting( + db, + "did:key:alias", + &[caveat_capability(resource, "tinycloud.sql/read", own)], + &[parent], + OffsetDateTime::now_utc(), + setting, + ) + .await + } + + fn not_subset_of_chain(error: &Error) -> bool { + matches!( + error, + Error::InvalidInvocation(InvocationError::CaveatsNotContained(reason)) + if reason == "invocation-caveats-not-subset-of-chain" + ) + } + + /// TC-1791: with the setting on, a stored delegated `tables` form does not + /// refuse an invocation whose own capability carries no caveat (the chain's + /// restriction is applied when the request runs), and an invocation cannot + /// carry the form itself. With the setting off the stored form is refused + /// as any non-profile caveat is. + #[tokio::test] + async fn stored_delegated_form_covers_an_invocation_only_with_the_setting_on() { + let resource = "tinycloud:did:key:owner/sql/db"; + let stored = delegated_form_caveat(serde_json::json!({"tables": ["a"]})); + let (db, parent) = caveat_chain_db(resource, "tinycloud.sql/read", stored).await; + holder_under(&db, parent, Caveats::default(), true) + .await + .expect("a stored delegated form covers an invocation without a caveat"); + + for own in [ + delegated_form_caveat(serde_json::json!({"tables": ["a"]})), + delegated_form_caveat(serde_json::json!({"tables": ["b"]})), + make_sql_constrained_caveat("get", "SELECT 1"), + ] { + let err = holder_under(&db, parent, own, true).await.unwrap_err(); + assert!( + matches!(&err, Error::InvalidInvocation(_)), + "unexpected rejection: {err:?}" + ); + } + + // Setting off: refused as any non-profile stored caveat is, and an + // invocation capability carrying the form is an unsupported caveat. + let err = holder_under(&db, parent, Caveats::default(), false) + .await + .unwrap_err(); + assert!(not_subset_of_chain(&err), "unexpected rejection: {err:?}"); + let err = holder_under( + &db, + parent, + delegated_form_caveat(serde_json::json!({"tables": ["a"]})), + false, + ) + .await + .unwrap_err(); + assert!( + matches!( + &err, + Error::InvalidInvocation(InvocationError::UnsupportedCaveat(_)) + ), + "unexpected rejection: {err:?}" + ); + } + + /// TC-1791: with the setting on, a stored grant of only empty entries + /// imposes nothing; with it off it is refused. + #[tokio::test] + async fn stored_grant_of_only_empty_entries_covers_any_invocation_only_with_the_setting_on() { + let resource = "tinycloud:did:key:owner/sql/db"; + let stored = Caveats(BTreeMap::from([ + ("0".to_string(), serde_json::json!({})), + ("1".to_string(), serde_json::json!({})), + ])); + let (db, parent) = caveat_chain_db(resource, "tinycloud.sql/read", stored).await; + for own in [ + Caveats::default(), + make_sql_constrained_caveat("get", "SELECT 1"), + ] { + holder_under(&db, parent, own.clone(), true) + .await + .expect("empty entries restrict nothing"); + let err = holder_under(&db, parent, own, false).await.unwrap_err(); + assert!(not_subset_of_chain(&err), "unexpected rejection: {err:?}"); + } + } + #[tokio::test] async fn duckdb_invocation_with_caveat_is_refused() { let resource = "tinycloud:did:key:owner/duckdb/db"; diff --git a/tinycloud-core/src/sql/authority.rs b/tinycloud-core/src/sql/authority.rs index bf9ece53..dd2b931f 100644 --- a/tinycloud-core/src/sql/authority.rs +++ b/tinycloud-core/src/sql/authority.rs @@ -10,8 +10,12 @@ use prometheus::{register_int_counter_vec, IntCounterVec}; use serde::{Deserialize, Serialize}; -use std::sync::LazyLock; +use std::sync::{ + atomic::{AtomicU8, Ordering}, + Arc, LazyLock, +}; +use super::types::CaveatArm; use crate::policy_capability::ability_matches; const ADMIN: &str = "tinycloud.sql/admin"; @@ -87,12 +91,58 @@ pub fn ddl_without_schema_count(kind: DdlKind) -> u64 { #[cfg(test)] pub(crate) static LOG_COUNTER_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +/// Which caveat arm refused a statement, recorded by the authorizer while the +/// statement is prepared. Clones share one cell. The arm with the highest +/// precedence wins: write, then table, then column. +#[derive(Clone, Debug, Default)] +pub struct DeniedArm(Arc); + +impl DeniedArm { + fn rank(arm: CaveatArm) -> u8 { + match arm { + CaveatArm::Column => 1, + CaveatArm::Table => 2, + CaveatArm::Write => 3, + CaveatArm::Chain => 4, + } + } + + pub fn record(&self, arm: CaveatArm) { + self.0.fetch_max(Self::rank(arm), Ordering::Relaxed); + } + + pub fn get(&self) -> Option { + match self.0.load(Ordering::Relaxed) { + 1 => Some(CaveatArm::Column), + 2 => Some(CaveatArm::Table), + 3 => Some(CaveatArm::Write), + 4 => Some(CaveatArm::Chain), + _ => None, + } + } + + pub fn clear(&self) { + self.0.store(0, Ordering::Relaxed); + } +} + +impl PartialEq for DeniedArm { + fn eq(&self, other: &Self) -> bool { + self.get() == other.get() + } +} + +impl Eq for DeniedArm {} + /// Every `tinycloud.sql/*` ability the invocation holds, plus the node's DDL -/// policy. +/// policy and, when the delegated-caveat rules apply to the request, the cell +/// that records which caveat arm refused a statement. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct SqlAuthority { held: Vec, ddl_policy: DdlPolicy, + delegated_caveats: bool, + denied: DeniedArm, } impl SqlAuthority { @@ -104,6 +154,8 @@ impl SqlAuthority { Self { held: held.into_iter().map(Into::into).collect(), ddl_policy: DdlPolicy::default(), + delegated_caveats: false, + denied: DeniedArm::default(), } } @@ -116,6 +168,24 @@ impl SqlAuthority { self.ddl_policy } + /// Judge the request's caveats by the delegated-caveat rules (TC-1791): + /// names match ASCII-case-insensitively, the authorizer is the one place + /// a `tables` / `columns` / `readOnly` restriction is decided, and it + /// records which arm refused. + pub fn with_delegated_caveats(mut self, enabled: bool) -> Self { + self.delegated_caveats = enabled; + self + } + + pub fn delegated_caveats(&self) -> bool { + self.delegated_caveats + } + + /// The cell the authorizer records a refusing arm in. + pub fn denied_arm(&self) -> &DeniedArm { + &self.denied + } + fn confers(&self, required: &str) -> bool { self.held.iter().any(|held| ability_matches(held, required)) } diff --git a/tinycloud-core/src/sql/authorizer.rs b/tinycloud-core/src/sql/authorizer.rs index 7a9b9bc5..5562e1f4 100644 --- a/tinycloud-core/src/sql/authorizer.rs +++ b/tinycloud-core/src/sql/authorizer.rs @@ -8,6 +8,7 @@ use rusqlite::hooks::{AuthAction, AuthContext, Authorization}; use super::authority::SqlAuthority; use super::caveats::SqlCaveats; use super::parser::ParsedQuery; +use super::types::CaveatArm; /// What the statement being authorized is, as the parser classified it. /// @@ -258,11 +259,178 @@ pub fn create_authorizer_scoped( /// As [`create_authorizer_scoped`], and records in `pragmas` that a PRAGMA /// outside the read-only reports was allowed. +/// +/// When `authority` carries the delegated-caveat rules +/// ([`SqlAuthority::with_delegated_caveats`]), the abilities decide first, as +/// always; then `caveats` are judged by [`delegated_denial`], and the arm that +/// refused is recorded in the authority's [`DeniedArm`](super::authority::DeniedArm). pub fn create_authorizer_tracked( caveats: Option, authority: &SqlAuthority, scope: StatementScope, pragmas: PragmaTracker, +) -> impl FnMut(AuthContext<'_>) -> Authorization { + let denied = authority.denied_arm().clone(); + let (ability_caveats, delegated_caveats) = if authority.delegated_caveats() { + (None, caveats) + } else { + (caveats, None) + }; + let mut judge = create_ability_authorizer(ability_caveats, authority, scope, pragmas); + move |ctx: AuthContext<'_>| { + let verdict = judge(ctx); + if verdict != Authorization::Allow { + return verdict; + } + match delegated_caveats + .as_ref() + .and_then(|caveats| delegated_denial(caveats, scope.is_read_only, &ctx)) + { + Some(arm) => { + denied.record(arm); + Authorization::Deny + } + None => Authorization::Allow, + } + } +} + +/// Which caveat arm refuses `ctx`, if one does (TC-1791). Precedence: write, +/// then table, then column. Names compare ASCII-case-insensitively. +/// +/// - `readOnly`: a statement the parser did not classify as read-only is +/// refused at its first callback, whatever the action. Otherwise every +/// write and DDL action, and a PRAGMA that sets a value, is refused. +/// - `tables`: every table an action reads, writes or changes must be listed, +/// including the tables a trigger, a foreign key or a view reaches, the +/// target of every DDL action, and `sqlite_master`. The PRAGMA schema +/// reports and checks, and the PRAGMAs that set a header value, are refused. +/// - `columns`: a read or an UPDATE of a column must list it; a read that +/// names no column (`count(*)`) is allowed. INSERT and DELETE name no +/// column and are refused. +pub(super) fn delegated_denial( + caveats: &SqlCaveats, + statement_is_read_only: bool, + ctx: &AuthContext<'_>, +) -> Option { + let read_only = caveats.read_only.unwrap_or(false); + if read_only && !statement_is_read_only { + return Some(CaveatArm::Write); + } + let table_arm = |table: &str| (!caveats.is_table_listed(table)).then_some(CaveatArm::Table); + let column_arm = + |column: &str| (!caveats.is_column_listed(column)).then_some(CaveatArm::Column); + match ctx.action { + AuthAction::Read { + table_name, + column_name, + } => table_arm(table_name).or_else(|| { + if column_name.is_empty() { + None + } else { + column_arm(column_name) + } + }), + AuthAction::Insert { table_name } | AuthAction::Delete { table_name } => read_only + .then_some(CaveatArm::Write) + .or_else(|| table_arm(table_name)) + .or_else(|| caveats.columns.is_some().then_some(CaveatArm::Column)), + AuthAction::Update { + table_name, + column_name, + } => { + // SQLite's own bookkeeping UPDATE of the schema table when a + // read-only query first uses a `pragma_*` function is not a write. + if statement_is_read_only && is_sqlite_schema_table(table_name) { + table_arm(table_name) + } else { + read_only + .then_some(CaveatArm::Write) + .or_else(|| table_arm(table_name)) + .or_else(|| column_arm(column_name)) + } + } + AuthAction::Pragma { + pragma_name, + pragma_value, + } => { + let restricted = caveats.tables.is_some() || caveats.columns.is_some(); + let is_report = pragma_in(&READ_ONLY_PRAGMAS, pragma_name); + let is_check = pragma_in(&ADMIN_CHECK_PRAGMAS, pragma_name); + // SQLite reports `PRAGMA name(arg)` and `PRAGMA name = value` alike + // as a PRAGMA with a value. The schema reports and the checks take + // an argument and change nothing; any other PRAGMA with a value may + // set something. The two header values a PRAGMA can set are + // database-wide state, not a table: a table or column limit + // refuses them as a write too. + let sets_something = pragma_value.is_some() && !is_report && !is_check; + let sets_header = + pragma_value.is_some() && pragma_in(&ADMIN_SETTABLE_PRAGMAS, pragma_name); + if (sets_something && read_only) || (sets_header && restricted) { + Some(CaveatArm::Write) + // The schema reports and the checks list tables and rowids. + } else if restricted && (is_report || is_check) { + Some(CaveatArm::Table) + } else { + None + } + } + // An action that names a table (a view counts as one: a grant lists + // it): `readOnly` refuses it, and under `tables` the table must be + // listed. + AuthAction::CreateTable { table_name } + | AuthAction::CreateTempTable { table_name } + | AuthAction::DropTable { table_name } + | AuthAction::DropTempTable { table_name } + | AuthAction::AlterTable { table_name, .. } + | AuthAction::CreateIndex { table_name, .. } + | AuthAction::CreateTempIndex { table_name, .. } + | AuthAction::DropIndex { table_name, .. } + | AuthAction::DropTempIndex { table_name, .. } + | AuthAction::CreateTrigger { table_name, .. } + | AuthAction::CreateTempTrigger { table_name, .. } + | AuthAction::DropTrigger { table_name, .. } + | AuthAction::DropTempTrigger { table_name, .. } + | AuthAction::Analyze { table_name } + | AuthAction::CreateVtable { table_name, .. } + | AuthAction::DropVtable { table_name, .. } + | AuthAction::CreateView { + view_name: table_name, + } + | AuthAction::CreateTempView { + view_name: table_name, + } + | AuthAction::DropView { + view_name: table_name, + } + | AuthAction::DropTempView { + view_name: table_name, + } => read_only + .then_some(CaveatArm::Write) + .or_else(|| table_arm(table_name)), + // REINDEX names an index; the table it belongs to is judged by the + // actions that build the index. + AuthAction::Reindex { .. } => read_only.then_some(CaveatArm::Write), + // No table is involved. The abilities refuse transaction control, + // ATTACH and DETACH for every holder. + AuthAction::Select + | AuthAction::Function { .. } + | AuthAction::Recursive + | AuthAction::Transaction { .. } + | AuthAction::Savepoint { .. } + | AuthAction::Attach { .. } + | AuthAction::Detach { .. } => None, + // An action this code does not know (the abilities refuse it for every + // holder): under `tables` it is refused rather than guessed at. + _ => caveats.tables.is_some().then_some(CaveatArm::Table), + } +} + +fn create_ability_authorizer( + caveats: Option, + authority: &SqlAuthority, + scope: StatementScope, + pragmas: PragmaTracker, ) -> impl FnMut(AuthContext<'_>) -> Authorization { let tier = Tier::new(authority, scope); let is_admin = tier.is_admin; diff --git a/tinycloud-core/src/sql/caveats.rs b/tinycloud-core/src/sql/caveats.rs index 55c73c9f..08477781 100644 --- a/tinycloud-core/src/sql/caveats.rs +++ b/tinycloud-core/src/sql/caveats.rs @@ -37,6 +37,24 @@ impl SqlCaveats { } } + /// As [`Self::is_table_allowed`], comparing ASCII-case-insensitively: the + /// rule of the delegated caveats (TC-1791), where SQLite names the table as + /// it was created. + pub fn is_table_listed(&self, table: &str) -> bool { + match &self.tables { + None => true, + Some(tables) => tables.iter().any(|t| t.eq_ignore_ascii_case(table)), + } + } + + /// As [`Self::is_column_allowed`], comparing ASCII-case-insensitively. + pub fn is_column_listed(&self, column: &str) -> bool { + match &self.columns { + None => true, + Some(columns) => columns.iter().any(|c| c.eq_ignore_ascii_case(column)), + } + } + pub fn is_write_allowed(&self) -> bool { !self.read_only.unwrap_or(false) } diff --git a/tinycloud-core/src/sql/database.rs b/tinycloud-core/src/sql/database.rs index dbf8daa7..6f0602b3 100644 --- a/tinycloud-core/src/sql/database.rs +++ b/tinycloud-core/src/sql/database.rs @@ -260,6 +260,7 @@ pub fn spawn_actor( response_tx, } => { let pragmas = PragmaTracker::default(); + authority.denied_arm().clear(); let result = match growth { GrowthPolicy::Allow => handle_message_authorized( &conn, &request, &caveats, &authority, &pragmas, @@ -268,6 +269,13 @@ pub fn spawn_actor( &conn, &request, &caveats, &authority, &pragmas, ), }; + // A statement the authorizer refused for a caveat fails the + // request with the arm that refused it, whatever text + // SQLite gave the refusal. + let result = match (result, authority.denied_arm().get()) { + (Err(_), Some(arm)) => Err(SqlError::CaveatViolation(arm)), + (result, _) => result, + }; conn.authorizer(None::) -> Authorization>); inject_open_transaction(&conn, &space_id, &db_name); diff --git a/tinycloud-core/src/sql/delegated_authorizer_tests.rs b/tinycloud-core/src/sql/delegated_authorizer_tests.rs new file mode 100644 index 00000000..e4731613 --- /dev/null +++ b/tinycloud-core/src/sql/delegated_authorizer_tests.rs @@ -0,0 +1,891 @@ +//! The delegated `tables` / `columns` / `readOnly` rules (TC-1791), run against +//! the real authorizer on the bundled SQLite: one table-driven test over every +//! statement of the case list (read from the arm tracker, never from SQLite's +//! error text), plus the rules that do not depend on a statement. + +use rusqlite::hooks::{AuthAction, AuthContext, TransactionOperation}; +use rusqlite::Connection; + +use super::authority::SqlAuthority; +use super::authorizer::{create_authorizer_scoped, delegated_denial, StatementScope}; +use super::caveats::SqlCaveats; +use super::parser; +use super::types::CaveatArm; + +/// Created as the owner, with no caveat. +const FIXTURE: &str = " +PRAGMA foreign_keys = ON; +CREATE TABLE Notes(id INTEGER PRIMARY KEY, body TEXT, secret TEXT); +CREATE TABLE t2(id INTEGER PRIMARY KEY, nid INT REFERENCES Notes(id) ON DELETE CASCADE, x TEXT); +CREATE TABLE plain(a, b); +CREATE VIEW v AS SELECT id, body FROM Notes; +CREATE TABLE log(m TEXT); +CREATE TRIGGER tr AFTER INSERT ON Notes BEGIN INSERT INTO log VALUES(new.body); END; +INSERT INTO Notes VALUES (1,'a','s'),(2,'b','t'); +INSERT INTO t2 VALUES (1,1,'q'); +INSERT INTO plain VALUES (1,2); +CREATE INDEX t2x ON t2(x); +"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Expect { + Allow, + Deny(CaveatArm), +} + +use CaveatArm::{Column, Table, Write}; +use Expect::{Allow, Deny}; + +struct Row { + id: &'static str, + sql: &'static str, + tables: Option<&'static [&'static str]>, + columns: Option<&'static [&'static str]>, + read_only: bool, + expect: Expect, +} + +const fn row( + id: &'static str, + sql: &'static str, + tables: Option<&'static [&'static str]>, + columns: Option<&'static [&'static str]>, + read_only: bool, + expect: Expect, +) -> Row { + Row { + id, + sql, + tables, + columns, + read_only, + expect, + } +} + +fn caveats(row: &Row) -> SqlCaveats { + SqlCaveats { + tables: row + .tables + .map(|t| t.iter().map(|n| n.to_string()).collect()), + columns: row + .columns + .map(|c| c.iter().map(|n| n.to_string()).collect()), + statements: None, + read_only: row.read_only.then_some(true), + } +} + +/// Runs `sql` on a fresh fixture database through the authorizer built for +/// `caveats`; returns whether it ran and which arm the tracker recorded. +fn run( + sql: &str, + caveats: Option, + authority: &SqlAuthority, +) -> (Result<(), rusqlite::Error>, Option) { + run_with(sql, caveats, authority, "") +} + +/// [`run`] on the fixture plus `setup`. +fn run_with( + sql: &str, + caveats: Option, + authority: &SqlAuthority, + setup: &str, +) -> (Result<(), rusqlite::Error>, Option) { + let conn = Connection::open_in_memory().expect("in-memory database"); + conn.execute_batch(FIXTURE).expect("fixture"); + conn.execute_batch(setup).expect("setup"); + // The scope classifies the statement; the abilities are judged by the + // authorizer, so classify with an authority that is never the reason for a + // refusal. + let parsed = + parser::validate_sql_authorized(sql, &None, &SqlAuthority::new(["tinycloud.sql/admin"])) + .expect("statement parses"); + conn.authorizer(Some(create_authorizer_scoped( + caveats, + authority, + StatementScope::of(&parsed), + ))); + let result = conn.execute_batch(sql); + conn.authorizer(None::) -> rusqlite::hooks::Authorization>); + (result, authority.denied_arm().get()) +} + +fn admin() -> SqlAuthority { + SqlAuthority::new(["tinycloud.sql/admin"]).with_delegated_caveats(true) +} + +const ROWS: &[Row] = &[ + // A. Reads and columns + row("A1", "SELECT body FROM Notes", Some(&["Notes"]), Some(&["body"]), false, Allow), + row("A2", "SELECT secret FROM Notes", Some(&["Notes"]), Some(&["body"]), false, Deny(Column)), + row("A3", "SELECT count(*) FROM Notes", Some(&["Notes"]), Some(&["body"]), false, Allow), + row("A4", "SELECT 1 FROM Notes", Some(&["Notes"]), Some(&["body"]), false, Allow), + row("A5", "SELECT * FROM Notes", Some(&["Notes"]), Some(&["id", "body"]), false, Deny(Column)), + row( + "A6", + "SELECT n.body FROM Notes n JOIN t2 ON t2.nid=n.id", + Some(&["Notes", "t2"]), + Some(&["body", "nid", "id"]), + false, + Allow, + ), + row( + "A7", + "SELECT n.body FROM Notes n JOIN t2 ON t2.nid=n.id", + Some(&["Notes"]), + None, + false, + Deny(Table), + ), + row("A8", "SELECT body FROM NOTES", Some(&["notes"]), None, false, Allow), + row("A9", "SELECT body FROM Notes", Some(&["NOTES"]), Some(&["BODY"]), false, Allow), + row( + "A10", + "WITH c AS (SELECT id FROM Notes) SELECT * FROM c", + Some(&["Notes"]), + Some(&["id"]), + false, + Allow, + ), + row( + "A11", + "SELECT id FROM Notes WHERE id IN (SELECT nid FROM t2)", + Some(&["Notes"]), + None, + false, + Deny(Table), + ), + row( + "A12", + "SELECT id FROM Notes UNION SELECT id FROM t2", + Some(&["Notes"]), + None, + false, + Deny(Table), + ), + row("A13", "SELECT a FROM plain", Some(&[]), None, false, Deny(Table)), + row("A14", "SELECT rowid FROM plain", Some(&["plain"]), Some(&["a"]), false, Deny(Column)), + row("A15", "SELECT a+b FROM plain", Some(&["plain"]), Some(&["a"]), false, Deny(Column)), + row("A16", "SELECT name FROM sqlite_master", Some(&["Notes"]), None, false, Deny(Table)), + row( + "A17", + "SELECT name FROM pragma_table_info('Notes')", + Some(&["plain"]), + None, + false, + Deny(Table), + ), + // A17b: listing the table-valued function is not enough, the PRAGMA it + // reports for the table argument is refused under a table restriction. + row( + "A17b", + "SELECT name FROM pragma_table_info('Notes')", + Some(&["pragma_table_info"]), + None, + false, + Deny(Table), + ), + // B. Views + row("B1", "SELECT * FROM v", Some(&["v"]), None, false, Deny(Table)), + row("B2", "SELECT * FROM v", Some(&["v", "Notes"]), None, false, Allow), + // C. Writes + row("C1", "INSERT INTO plain(a) VALUES (9)", Some(&["plain"]), None, false, Allow), + row( + "C2", + "INSERT INTO plain(a) VALUES (9)", + Some(&["plain"]), + Some(&["a"]), + false, + Deny(Column), + ), + row("C3", "INSERT INTO plain(a) VALUES (9)", Some(&["Notes"]), None, false, Deny(Table)), + row("C4", "INSERT INTO plain(a) VALUES (9)", None, None, true, Deny(Write)), + row( + "C5", + "INSERT INTO plain(a) SELECT id FROM Notes", + Some(&["plain"]), + None, + false, + Deny(Table), + ), + row( + "C6", + "INSERT INTO plain(a) SELECT id FROM Notes", + Some(&["plain", "Notes"]), + None, + false, + Allow, + ), + row("C7", "DELETE FROM plain WHERE a=1", Some(&["plain"]), None, false, Allow), + row( + "C8", + "DELETE FROM plain WHERE a=1", + Some(&["plain"]), + Some(&["a"]), + false, + Deny(Column), + ), + row( + "C9", + "UPDATE Notes SET body='x' WHERE id=1", + Some(&["Notes"]), + Some(&["body", "id"]), + false, + Allow, + ), + row( + "C10", + "UPDATE Notes SET body='x' WHERE id=1", + Some(&["Notes"]), + Some(&["body"]), + false, + Deny(Column), + ), + row( + "C11", + "UPDATE Notes SET secret='x' WHERE id=1", + Some(&["Notes"]), + Some(&["body", "id"]), + false, + Deny(Column), + ), + row("C12", "UPDATE Notes SET body='x' WHERE id=1", None, None, true, Deny(Write)), + row( + "C13", + "INSERT INTO Notes(id,body) VALUES (1,'z') ON CONFLICT(id) DO UPDATE SET body=excluded.body", + Some(&["Notes"]), + None, + false, + Deny(Table), + ), + row( + "C14", + "INSERT INTO plain(a) VALUES (9) RETURNING b", + Some(&["plain"]), + Some(&["a"]), + false, + Deny(Column), + ), + row("C15", "REPLACE INTO plain(a,b) VALUES (1,2)", Some(&["plain"]), None, false, Allow), + row("C15-readonly", "REPLACE INTO plain(a,b) VALUES (1,2)", None, None, true, Deny(Write)), + // D. Side effects the authorizer sees (triggers, foreign keys) + row("D1", "INSERT INTO Notes(id,body) VALUES (7,'t')", Some(&["Notes"]), None, false, Deny(Table)), + row( + "D2", + "INSERT INTO Notes(id,body) VALUES (7,'t')", + Some(&["Notes", "log"]), + None, + false, + Deny(Table), + ), + row("D3", "DELETE FROM Notes WHERE id=1", Some(&["Notes"]), None, false, Deny(Table)), + row("D4", "DELETE FROM Notes WHERE id=1", Some(&["Notes", "t2"]), None, false, Allow), + // E. readOnly beyond DML + row("E1", "CREATE TABLE z(a)", None, None, true, Deny(Write)), + row("E2", "PRAGMA user_version = 5", None, None, true, Deny(Write)), + row("E3", "PRAGMA user_version", None, None, true, Allow), + // M. DDL judged against the table it names (`sqlite_master` listed so + // that the bookkeeping of a schema change is not the reason). + row("M1", "ALTER TABLE t2 ADD COLUMN z", Some(&["Notes", "sqlite_master"]), None, false, Deny(Table)), + row("M1b", "ALTER TABLE t2 ADD COLUMN z", Some(&["t2", "sqlite_master"]), None, false, Allow), + row("M2", "DROP INDEX t2x", Some(&["Notes", "sqlite_master"]), None, false, Deny(Table)), + row("M2b", "DROP INDEX t2x", Some(&["t2", "sqlite_master"]), None, false, Allow), + row("M3", "CREATE INDEX i2 ON plain(a)", Some(&["Notes", "sqlite_master"]), None, false, Deny(Table)), + row("M3b", "CREATE INDEX i2 ON plain(a)", Some(&["plain", "sqlite_master"]), None, false, Allow), + row("M4", "CREATE TABLE z(a)", Some(&["Notes", "sqlite_master"]), None, false, Deny(Table)), + row("M4b", "CREATE TABLE z(a)", Some(&["z", "sqlite_master"]), None, false, Allow), + row("M5", "CREATE VIEW v2 AS SELECT id FROM Notes", Some(&["Notes", "sqlite_master"]), None, false, Deny(Table)), + row("M5b", "CREATE VIEW v2 AS SELECT id FROM Notes", Some(&["Notes", "v2", "sqlite_master"]), None, false, Allow), + row("M6", "DROP VIEW v", Some(&["Notes", "sqlite_master"]), None, false, Deny(Table)), + row("M6b", "DROP VIEW v", Some(&["v", "sqlite_master"]), None, false, Allow), + row("M7", "DROP TABLE plain", Some(&["Notes", "sqlite_master"]), None, false, Deny(Table)), + row("M7b", "DROP TABLE plain", Some(&["plain", "sqlite_master"]), None, false, Allow), + row("M8", "CREATE TEMP TABLE tt(a)", Some(&["Notes", "sqlite_temp_master"]), None, false, Deny(Table)), + row("M8b", "CREATE TEMP TABLE tt(a)", Some(&["tt", "sqlite_temp_master"]), None, false, Allow), + row("M9", "ALTER TABLE t2 ADD COLUMN z", Some(&["Notes", "sqlite_master"]), None, true, Deny(Write)), + // P. PRAGMAs under a table or column limit. + row("P1", "PRAGMA foreign_key_check", Some(&["Notes"]), None, false, Deny(Table)), + row("P2", "PRAGMA integrity_check", None, Some(&["a"]), false, Deny(Table)), + row("P3", "PRAGMA quick_check", Some(&["plain"]), None, false, Deny(Table)), + row("P4", "PRAGMA user_version = 5", Some(&["plain"]), None, false, Deny(Write)), + row("P5", "PRAGMA application_id = 7", None, Some(&["a"]), false, Deny(Write)), + row("P6", "PRAGMA user_version", Some(&["plain"]), None, false, Allow), + row("P7", "PRAGMA integrity_check", None, None, true, Allow), +]; + +#[test] +fn every_case_of_the_list_is_decided_by_the_arm_the_tracker_records() { + let mut mismatches = Vec::new(); + for row in ROWS { + let authority = admin(); + let (result, arm) = run(row.sql, Some(caveats(row)), &authority); + let observed = match (result.is_ok(), arm) { + (true, None) => Allow, + (false, Some(arm)) => Deny(arm), + (ran, arm) => panic!( + "{}: statement ran = {ran} but the recorded arm is {arm:?} (every refusal \ + must record an arm, an allowed statement none)", + row.id + ), + }; + if observed != row.expect { + mismatches.push(format!( + "{}: expected {:?}, observed {:?}", + row.id, row.expect, observed + )); + } + } + assert!(mismatches.is_empty(), "{}", mismatches.join("\n")); +} + +#[test] +fn a_refusal_records_only_the_highest_precedence_arm() { + // readOnly and an unlisted table in one write: the write arm names it. + let authority = admin(); + let caveats = SqlCaveats { + tables: Some(vec!["Notes".to_string()]), + read_only: Some(true), + ..SqlCaveats::default() + }; + let (result, arm) = run("INSERT INTO plain(a) VALUES (9)", Some(caveats), &authority); + assert!(result.is_err()); + assert_eq!(arm, Some(Write)); +} + +#[test] +fn the_abilities_decide_before_the_caveats() { + // A read-tier grant may not write whatever the caveat lists; the refusal + // is the ability's and records no caveat arm. + let authority = SqlAuthority::new(["tinycloud.sql/write"]).with_delegated_caveats(true); + let caveats = SqlCaveats { + tables: Some(vec!["plain".to_string()]), + ..SqlCaveats::default() + }; + // `write` may insert into a listed table. + let (result, arm) = run( + "INSERT INTO plain(a) VALUES (9)", + Some(caveats.clone()), + &authority, + ); + assert!(result.is_ok()); + assert_eq!(arm, None); + // DDL needs `schema`; `write` alone is refused by the ability, no arm. + let (result, arm) = run("CREATE TABLE z(a)", Some(caveats), &authority); + assert!(result.is_err()); + assert_eq!(arm, None); +} + +#[test] +fn without_the_delegated_rules_names_compare_exactly() { + // Not delegated: table and column names compare exactly, and a read that + // names no column is judged as the column "". + let authority = SqlAuthority::new(["tinycloud.sql/admin"]); + let columns_only = SqlCaveats { + columns: Some(vec!["body".to_string()]), + ..SqlCaveats::default() + }; + let (result, arm) = run("SELECT count(*) FROM Notes", Some(columns_only), &authority); + assert!(result.is_err()); + assert_eq!(arm, None, "no arm is recorded without the delegated rules"); + + let lower = SqlCaveats { + tables: Some(vec!["notes".to_string()]), + ..SqlCaveats::default() + }; + let (result, arm) = run("SELECT body FROM Notes", Some(lower), &authority); + assert!(result.is_err()); + assert_eq!(arm, None); +} + +#[test] +fn schema_reports_are_refused_under_a_table_or_column_restriction() { + for (caveats, expected) in [ + ( + SqlCaveats { + tables: Some(vec!["plain".to_string()]), + ..SqlCaveats::default() + }, + Deny(Table), + ), + ( + SqlCaveats { + columns: Some(vec!["a".to_string()]), + ..SqlCaveats::default() + }, + Deny(Table), + ), + ( + SqlCaveats { + read_only: Some(true), + ..SqlCaveats::default() + }, + Allow, + ), + ] { + let authority = admin(); + let (result, arm) = run("PRAGMA table_info(Notes)", Some(caveats), &authority); + let observed = match arm { + Some(arm) => Deny(arm), + None => { + assert!(result.is_ok()); + Allow + } + }; + assert_eq!(observed, expected); + } +} + +/// What `delegated_denial` answers for one rusqlite action, under a `tables` +/// limit that lists `listed` only (with a table that is not listed, and with +/// the listed one) and under `readOnly`. +struct Audit { + variant: &'static str, + action: for<'a> fn(&'a str) -> AuthAction<'a>, + unlisted: Option, + listed: Option, + read_only: Option, +} + +macro_rules! audit { + ($variant:literal, $action:expr, $unlisted:expr, $listed:expr, $read_only:expr) => { + Audit { + variant: $variant, + action: $action, + unlisted: $unlisted, + listed: $listed, + read_only: $read_only, + } + }; +} + +/// One entry per variant of rusqlite 0.32's `AuthAction` (and per PRAGMA +/// form). Actions that name a table are judged against it; the others involve +/// no table and say so. +fn audit_table() -> Vec { + vec![ + // Actions that name a table: the table arm unless it is listed; + // `readOnly` refuses every one that changes anything. + audit!( + "CreateIndex", + |t| AuthAction::CreateIndex { + index_name: "i", + table_name: t + }, + Some(Table), + None, + Some(Write) + ), + audit!( + "CreateTable", + |t| AuthAction::CreateTable { table_name: t }, + Some(Table), + None, + Some(Write) + ), + audit!( + "CreateTempIndex", + |t| AuthAction::CreateTempIndex { + index_name: "i", + table_name: t + }, + Some(Table), + None, + Some(Write) + ), + audit!( + "CreateTempTable", + |t| AuthAction::CreateTempTable { table_name: t }, + Some(Table), + None, + Some(Write) + ), + audit!( + "CreateTempTrigger", + |t| AuthAction::CreateTempTrigger { + trigger_name: "g", + table_name: t + }, + Some(Table), + None, + Some(Write) + ), + audit!( + "CreateTempView", + |t| AuthAction::CreateTempView { view_name: t }, + Some(Table), + None, + Some(Write) + ), + audit!( + "CreateTrigger", + |t| AuthAction::CreateTrigger { + trigger_name: "g", + table_name: t + }, + Some(Table), + None, + Some(Write) + ), + audit!( + "CreateView", + |t| AuthAction::CreateView { view_name: t }, + Some(Table), + None, + Some(Write) + ), + audit!( + "Delete", + |t| AuthAction::Delete { table_name: t }, + Some(Table), + None, + Some(Write) + ), + audit!( + "DropIndex", + |t| AuthAction::DropIndex { + index_name: "i", + table_name: t + }, + Some(Table), + None, + Some(Write) + ), + audit!( + "DropTable", + |t| AuthAction::DropTable { table_name: t }, + Some(Table), + None, + Some(Write) + ), + audit!( + "DropTempIndex", + |t| AuthAction::DropTempIndex { + index_name: "i", + table_name: t + }, + Some(Table), + None, + Some(Write) + ), + audit!( + "DropTempTable", + |t| AuthAction::DropTempTable { table_name: t }, + Some(Table), + None, + Some(Write) + ), + audit!( + "DropTempTrigger", + |t| AuthAction::DropTempTrigger { + trigger_name: "g", + table_name: t + }, + Some(Table), + None, + Some(Write) + ), + audit!( + "DropTempView", + |t| AuthAction::DropTempView { view_name: t }, + Some(Table), + None, + Some(Write) + ), + audit!( + "DropTrigger", + |t| AuthAction::DropTrigger { + trigger_name: "g", + table_name: t + }, + Some(Table), + None, + Some(Write) + ), + audit!( + "DropView", + |t| AuthAction::DropView { view_name: t }, + Some(Table), + None, + Some(Write) + ), + audit!( + "Insert", + |t| AuthAction::Insert { table_name: t }, + Some(Table), + None, + Some(Write) + ), + audit!( + "Read", + |t| AuthAction::Read { + table_name: t, + column_name: "c" + }, + Some(Table), + None, + None + ), + audit!( + "Update", + |t| AuthAction::Update { + table_name: t, + column_name: "c" + }, + Some(Table), + None, + Some(Write) + ), + audit!( + "AlterTable", + |t| AuthAction::AlterTable { + database_name: "main", + table_name: t + }, + Some(Table), + None, + Some(Write) + ), + audit!( + "Analyze", + |t| AuthAction::Analyze { table_name: t }, + Some(Table), + None, + Some(Write) + ), + audit!( + "CreateVtable", + |t| AuthAction::CreateVtable { + table_name: t, + module_name: "m" + }, + Some(Table), + None, + Some(Write) + ), + audit!( + "DropVtable", + |t| AuthAction::DropVtable { + table_name: t, + module_name: "m" + }, + Some(Table), + None, + Some(Write) + ), + // PRAGMA: a schema report or a check lists tables whichever table is + // named; a PRAGMA that sets a header value is database-wide state. + audit!( + "Pragma table_info(t)", + |t| AuthAction::Pragma { + pragma_name: "table_info", + pragma_value: Some(t) + }, + Some(Table), + Some(Table), + None + ), + audit!( + "Pragma foreign_key_check", + |_| AuthAction::Pragma { + pragma_name: "foreign_key_check", + pragma_value: None + }, + Some(Table), + Some(Table), + None + ), + audit!( + "Pragma integrity_check", + |_| AuthAction::Pragma { + pragma_name: "integrity_check", + pragma_value: None + }, + Some(Table), + Some(Table), + None + ), + audit!( + "Pragma quick_check", + |_| AuthAction::Pragma { + pragma_name: "quick_check", + pragma_value: None + }, + Some(Table), + Some(Table), + None + ), + audit!( + "Pragma user_version = n", + |_| AuthAction::Pragma { + pragma_name: "user_version", + pragma_value: Some("5") + }, + Some(Write), + Some(Write), + Some(Write) + ), + audit!( + "Pragma application_id = n", + |_| AuthAction::Pragma { + pragma_name: "application_id", + pragma_value: Some("5") + }, + Some(Write), + Some(Write), + Some(Write) + ), + audit!( + "Pragma user_version", + |_| AuthAction::Pragma { + pragma_name: "user_version", + pragma_value: None + }, + None, + None, + None + ), + // REINDEX names an index; the table it belongs to is judged by the + // actions that build the index. + audit!( + "Reindex", + |t| AuthAction::Reindex { index_name: t }, + None, + None, + Some(Write) + ), + // No table is involved. The abilities refuse transaction control, + // ATTACH and DETACH for every holder. + audit!("Select", |_| AuthAction::Select, None, None, None), + audit!( + "Function", + |_| AuthAction::Function { + function_name: "count" + }, + None, + None, + None + ), + audit!("Recursive", |_| AuthAction::Recursive, None, None, None), + audit!( + "Transaction", + |_| AuthAction::Transaction { + operation: TransactionOperation::Begin + }, + None, + None, + None + ), + audit!( + "Savepoint", + |_| AuthAction::Savepoint { + operation: TransactionOperation::Begin, + savepoint_name: "s" + }, + None, + None, + None + ), + audit!( + "Attach", + |_| AuthAction::Attach { filename: "f" }, + None, + None, + None + ), + audit!( + "Detach", + |_| AuthAction::Detach { database_name: "d" }, + None, + None, + None + ), + // An action this code does not know: refused under `tables`. + audit!( + "Unknown", + |_| AuthAction::Unknown { + code: -1, + arg1: None, + arg2: None + }, + Some(Table), + Some(Table), + None + ), + ] +} + +#[test] +fn every_authorizer_action_is_judged_against_the_table_it_names_or_involves_none() { + let tables = SqlCaveats { + tables: Some(vec!["listed".to_string()]), + ..SqlCaveats::default() + }; + let read_only = SqlCaveats { + read_only: Some(true), + ..SqlCaveats::default() + }; + let mut mismatches = Vec::new(); + let audit = audit_table(); + assert_eq!(audit.len(), 40, "one entry per action and PRAGMA form"); + for entry in &audit { + for (name, caveats, expected) in [ + ("unlisted", &tables, entry.unlisted), + ("listed", &tables, entry.listed), + ("readOnly", &read_only, entry.read_only), + ] { + let table = if name == "listed" { "listed" } else { "other" }; + let ctx = AuthContext { + action: (entry.action)(table), + database_name: Some("main"), + accessor: None, + }; + let observed = delegated_denial(caveats, true, &ctx); + if observed != expected { + mismatches.push(format!( + "{} ({name}): expected {expected:?}, observed {observed:?}", + entry.variant + )); + } + } + // A statement the parser classed as a write is refused under + // `readOnly` at whatever callback comes first. + let ctx = AuthContext { + action: (entry.action)("other"), + database_name: Some("main"), + accessor: None, + }; + if delegated_denial(&read_only, false, &ctx) != Some(Write) { + mismatches.push(format!("{} (readOnly, write statement)", entry.variant)); + } + } + assert!(mismatches.is_empty(), "{}", mismatches.join("\n")); +} + +/// Only the ASCII letters fold, as in SQLite: the Kelvin sign is not `k`. +#[test] +fn non_ascii_names_match_only_exactly() { + const SETUP: &str = "CREATE TABLE Kelvin(a); CREATE TABLE \"\u{212A}x\"(a);"; + for (listed, sql, expected) in [ + ("\u{212A}elvin", "SELECT a FROM Kelvin", Deny(Table)), + ("kx", "SELECT a FROM \"\u{212A}x\"", Deny(Table)), + ("\u{212A}X", "SELECT a FROM \"\u{212A}x\"", Allow), + ("kelvin", "SELECT a FROM Kelvin", Allow), + ] { + let authority = admin(); + let caveats = SqlCaveats { + tables: Some(vec![listed.to_string()]), + ..SqlCaveats::default() + }; + let (result, arm) = run_with(sql, Some(caveats), &authority, SETUP); + let observed = match arm { + Some(arm) => Deny(arm), + None => { + assert!(result.is_ok(), "{listed:?} {sql}"); + Allow + } + }; + assert_eq!(observed, expected, "{listed:?} {sql}"); + } +} diff --git a/tinycloud-core/src/sql/delegated_caveats.rs b/tinycloud-core/src/sql/delegated_caveats.rs new file mode 100644 index 00000000..e150c13b --- /dev/null +++ b/tinycloud-core/src/sql/delegated_caveats.rs @@ -0,0 +1,812 @@ +//! Delegated `tables` / `columns` / `readOnly` caveats on `tinycloud.sql/*` +//! abilities (TC-1791). +//! +//! Wire: the caveats of one ability are a list of objects; the node sees them +//! as a map keyed by entry index (`"0"`, `"1"`, ...). An entry is one of +//! +//! - empty (`{}`): no restriction, +//! - a constrained-statements profile (a `mode` key, or the nested +//! `{"constrained-statements": {...}}` form): read by the profile rules, +//! - the delegated form: keys exactly a subset of `tables`, `columns`, +//! `readOnly`. +//! +//! A list with a delegated entry has exactly one entry: entries are +//! alternatives, so a second entry (empty or not) would read as "or +//! unrestricted". +//! +//! Names are matched ASCII-case-insensitively and exactly otherwise (no +//! wildcards, no `table.column`). Each list has 1..=[`MAX_NAMES`] elements, each +//! element 1..=[`MAX_NAME_BYTES`] bytes of UTF-8 without a `.`, without control +//! characters and without leading or trailing whitespace. + +use std::collections::BTreeSet; + +use serde_json::{Map, Value}; + +use super::caveats::SqlCaveats; +use crate::policy_capability::sql_caveat; +use crate::types::Caveats; + +/// Most elements in one `tables` or `columns` list. +pub const MAX_NAMES: usize = 256; +/// Longest element of a `tables` or `columns` list, in bytes. +pub const MAX_NAME_BYTES: usize = 128; + +const FORM_KEYS: [&str; 3] = ["tables", "columns", "readOnly"]; + +/// How strictly a stored or presented caveat map is read. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Reading { + /// A delegation being admitted: a `mode` entry beside `tables` or + /// `columns` is refused. + Ingress, + /// A row already stored: a `mode` entry is a profile whatever else it + /// carries, as it has always been read. + Stored, +} + +/// A caveat map that cannot be accepted or evaluated. `key` is the entry +/// index; `detail` is a fixed phrase that carries no caveat content. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Refusal { + pub key: String, + pub detail: &'static str, +} + +impl Refusal { + fn new(key: &str, detail: &'static str) -> Self { + Self { + key: key.to_string(), + detail, + } + } + + /// The reason text of the `unsupported-caveat` refusal. + pub fn reason(&self, ability: &str) -> String { + format!("{ability} caveat {} {}", self.key, self.detail) + } +} + +/// The restriction one ability's caveats place on SQL access. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct DelegatedCaveat { + /// Lower-cased table names; `None` = no restriction on tables. + tables: Option>, + /// Lower-cased column names; `None` = no restriction on columns. + columns: Option>, + /// `readOnly: true`; absent and `false` are the same. + read_only: bool, +} + +/// What one ability's caveat map says. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AbilityCaveats { + /// Empty, or only empty entries. + Unrestricted, + /// One or more constrained-statements profiles. + Profile, + /// A delegated-form entry. + Form(DelegatedCaveat), +} + +fn name_refusal(value: &Value) -> Option<&'static str> { + let Some(name) = value.as_str() else { + return Some("has an element that is not a string"); + }; + if name.is_empty() { + return Some("has an empty element"); + } + if name.len() > MAX_NAME_BYTES { + return Some("has an element that is too long"); + } + if name.contains('.') { + return Some("has an element with a dot"); + } + if name.chars().any(char::is_control) { + return Some("has an element with a control character"); + } + if name.starts_with(char::is_whitespace) || name.ends_with(char::is_whitespace) { + return Some("has an element with leading or trailing whitespace"); + } + None +} + +fn names(value: &Value) -> Result, &'static str> { + let Some(list) = value.as_array() else { + return Err("has a name list that is not an array"); + }; + if list.len() > MAX_NAMES { + return Err("has a name list with too many elements"); + } + list.iter() + .map(|element| match name_refusal(element) { + Some(detail) => Err(detail), + None => Ok(fold(element.as_str().unwrap_or_default())), + }) + .collect() +} + +/// The comparison form of a table or column name. +pub fn fold(name: &str) -> String { + name.to_ascii_lowercase() +} + +impl DelegatedCaveat { + fn from_entry(entry: &Map) -> Result { + if entry.keys().any(|key| !FORM_KEYS.contains(&key.as_str())) { + return Err("has an unsupported key"); + } + let read_only = match entry.get("readOnly") { + None => false, + Some(Value::Bool(flag)) => *flag, + Some(_) => return Err("has a readOnly that is not a boolean"), + }; + Ok(Self { + tables: entry.get("tables").map(names).transpose()?, + columns: entry.get("columns").map(names).transpose()?, + read_only, + }) + } + + pub fn is_unrestricted(&self) -> bool { + self.tables.is_none() && self.columns.is_none() && !self.read_only + } + + /// Whether `child` is at least as narrow as `self` on every axis. The + /// reason names the axis and carries no caveat content. + pub fn contains(&self, child: &Self) -> Result<(), &'static str> { + fn subset( + parent: &Option>, + child: &Option>, + reason: &'static str, + ) -> Result<(), &'static str> { + match (parent, child) { + (None, _) => Ok(()), + (Some(parent), Some(child)) if child.is_subset(parent) => Ok(()), + _ => Err(reason), + } + } + subset(&self.tables, &child.tables, "sql-tables-not-contained")?; + subset(&self.columns, &child.columns, "sql-columns-not-contained")?; + if self.read_only && !child.read_only { + return Err("sql-read-only-not-contained"); + } + Ok(()) + } + + /// Narrows `self` by `other`: tables and columns intersect, `readOnly` is + /// an OR. Never widens. + pub fn intersect(&mut self, other: &Self) { + fn meet(mine: &mut Option>, theirs: &Option>) { + match (mine.as_mut(), theirs) { + (_, None) => {} + (None, Some(theirs)) => *mine = Some(theirs.clone()), + (Some(mine), Some(theirs)) => mine.retain(|name| theirs.contains(name)), + } + } + meet(&mut self.tables, &other.tables); + meet(&mut self.columns, &other.columns); + self.read_only |= other.read_only; + } + + /// The executor form: `None` when nothing is restricted. Names are the + /// folded forms; the authorizer compares ASCII-case-insensitively. + pub fn to_sql_caveats(&self) -> Option { + (!self.is_unrestricted()).then(|| SqlCaveats { + tables: self.tables.as_ref().map(|t| t.iter().cloned().collect()), + columns: self.columns.as_ref().map(|c| c.iter().cloned().collect()), + statements: None, + read_only: self.read_only.then_some(true), + }) + } + + /// `self` narrowed by the caveats the invoker wrote into the invocation + /// facts. The facts only narrow; their statements are carried as given. + pub fn narrowed_by_facts(&self, facts: Option) -> Option { + let Some(facts) = facts else { + return self.to_sql_caveats(); + }; + let fact_form = Self { + tables: facts + .tables + .as_ref() + .map(|t| t.iter().map(|n| fold(n)).collect()), + columns: facts + .columns + .as_ref() + .map(|c| c.iter().map(|n| fold(n)).collect()), + read_only: facts.read_only.unwrap_or(false), + }; + let mut merged = self.clone(); + merged.intersect(&fact_form); + Some(SqlCaveats { + tables: merged.tables.map(|t| t.into_iter().collect()), + columns: merged.columns.map(|c| c.into_iter().collect()), + statements: facts.statements, + read_only: merged.read_only.then_some(true), + }) + } +} + +/// Reads one ability's caveat map. Refusal at `Reading::Ingress` is the +/// `unsupported-caveat` refusal; at `Reading::Stored` it means the row cannot +/// be evaluated and the request fails closed. +pub fn classify(caveats: &Caveats, reading: Reading) -> Result { + let mut forms = Vec::new(); + let mut profiles = 0usize; + let mut empties = 0usize; + let mut first_empty = None; + for (key, value) in &caveats.0 { + let Some(entry) = value.as_object() else { + return Err(Refusal::new(key, "is not an object")); + }; + if entry.is_empty() { + empties += 1; + first_empty.get_or_insert(key); + } else if let Some(inner) = entry.get("constrained-statements") { + if entry.len() != 1 { + return Err(Refusal::new(key, "has keys beside constrained-statements")); + } + if sql_caveat::parse(inner).is_err() { + return Err(Refusal::new(key, "is not a constrained-statements profile")); + } + profiles += 1; + } else if entry.contains_key("mode") { + if reading == Reading::Ingress + && (entry.contains_key("tables") || entry.contains_key("columns")) + { + return Err(Refusal::new( + key, + "mixes a constrained-statements profile with tables or columns", + )); + } + if sql_caveat::parse(value).is_err() { + return Err(Refusal::new(key, "is not a constrained-statements profile")); + } + profiles += 1; + } else { + let form = + DelegatedCaveat::from_entry(entry).map_err(|detail| Refusal::new(key, detail))?; + forms.push((key, form)); + } + } + if let Some((key, form)) = forms.first() { + if forms.len() + profiles + empties != 1 { + return Err(Refusal::new(key, "is one of several entries")); + } + return Ok(AbilityCaveats::Form(form.clone())); + } + if profiles > 0 { + return match first_empty { + // An empty entry beside a profile is not a profile. + Some(key) => Err(Refusal::new(key, "is not a constrained-statements profile")), + None => Ok(AbilityCaveats::Profile), + }; + } + Ok(AbilityCaveats::Unrestricted) +} + +/// The delegated restriction a stored chain places on one invocation. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ChainCaveat { + /// Intersection of the delegated forms on the chain. + pub form: DelegatedCaveat, + /// At least one hop on the chain carries a delegated form. + pub has_form: bool, + /// At least one hop on the chain carries a constrained-statements profile. + pub has_profile: bool, +} + +/// Why a chain's caveats could not be evaluated. Every variant refuses the +/// request. +#[derive(Debug)] +pub enum ChainCaveatError { + /// A hop's caveat does not parse, or a hop's row is missing. + Unevaluable, + /// The chain is longer than the traversal bound. + LimitExceeded, + Db(sea_orm::DbErr), +} + +/// Intersects the delegated forms of every row that grants one of the invoked +/// capabilities. A row that does not match carries no weight; a matching row +/// without a restriction contributes nothing; a matching row whose caveat does +/// not parse fails the whole evaluation. +pub fn chain_caveat_of_rows<'a>( + rows: impl IntoIterator, + invoked: &[crate::util::Capability], +) -> Result { + let mut chain = ChainCaveat::default(); + for row in rows { + let matches = invoked.iter().any(|capability| { + capability.resource.extends(&row.resource) + && crate::policy_capability::ability_matches( + row.ability.as_ref().as_ref(), + capability.ability.as_ref().as_ref(), + ) + }); + if !matches { + continue; + } + match classify(&row.caveats, Reading::Stored) { + Err(_) => return Err(ChainCaveatError::Unevaluable), + Ok(AbilityCaveats::Unrestricted) => {} + Ok(AbilityCaveats::Profile) => chain.has_profile = true, + Ok(AbilityCaveats::Form(form)) => { + chain.form.intersect(&form); + chain.has_form = true; + } + } + } + Ok(chain) +} + +/// The chain caveat of an invocation that cites `parents`: every stored +/// delegation reachable from them, however it was admitted. +pub async fn chain_caveat( + db: &C, + parents: &[crate::hash::Hash], + invoked: &[crate::util::Capability], +) -> Result { + use crate::auth_graph::{AuthGraphSnapshot, ChainTraversalError}; + let graph = AuthGraphSnapshot::load(db, parents) + .await + .map_err(|error| match error { + ChainTraversalError::Db(error) => ChainCaveatError::Db(error), + ChainTraversalError::LimitExceeded => ChainCaveatError::LimitExceeded, + })?; + let mut seen = BTreeSet::new(); + let mut rows = Vec::new(); + for root in parents { + for id in graph.chain_ids_from(root) { + if !seen.insert(id) { + continue; + } + if graph.delegation(&id).is_none() { + return Err(ChainCaveatError::Unevaluable); + } + rows.extend(graph.abilities(&id)); + } + } + chain_caveat_of_rows(rows, invoked) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::delegation::unsupported_sql_caveat_at_ingress; + use serde_json::json; + + const SQL: &str = "tinycloud.sql/read"; + + /// What the node sees for one ability's caveat list: the capability + /// parser collapses `[{}]` to no caveat and keeps every other list as + /// entries keyed by index (pinned by `util::tests`). + fn wire(list: Value) -> Caveats { + let entries = list.as_array().expect("a list"); + if entries.len() == 1 && entries[0] == json!({}) { + return Caveats::default(); + } + Caveats( + entries + .iter() + .enumerate() + .map(|(index, entry)| (index.to_string(), entry.clone())) + .collect(), + ) + } + + fn ingress(list: Value) -> Option { + unsupported_sql_caveat_at_ingress(SQL, &wire(list), true) + } + + fn form(list: Value) -> DelegatedCaveat { + match classify(&wire(list), Reading::Ingress) { + Ok(AbilityCaveats::Form(form)) => form, + other => panic!("not a delegated form: {other:?}"), + } + } + + fn profile() -> Value { + json!({"mode": "constrained-statements", "readOnly": true, + "statements": [{"name": "get", "sql": "SELECT 1"}]}) + } + + fn names(count: usize) -> Vec { + (0..count).map(|i| format!("t{i}")).collect() + } + + #[test] + fn valid_forms_are_accepted() { + for list in [ + json!([]), // V1 + json!([{}]), // V2 + json!([{"tables": ["Notes"]}]), // V3 + json!([{"columns": ["body", "id"]}]), // V4 + json!([{"readOnly": true}]), // V5 + json!([{"tables": ["Notes", "t2"], "columns": ["body"], "readOnly": true}]), // V6 + json!([{"readOnly": false}]), // V7 + json!([{"tables": []}]), // V8 + json!([{"tables": ["NOTES"]}]), // V9 + json!([profile()]), // V10 (mode form) + json!([{"constrained-statements": profile()}]), // V10 (nested) + json!([{"tables": names(256)}]), // V11 + json!([{}, {}]), // V12 + json!([{"tables": ["a".repeat(MAX_NAME_BYTES)]}]), // longest element + ] { + assert_eq!(ingress(list.clone()), None, "{list}"); + } + // V9: equal after folding. + assert_eq!( + form(json!([{"tables": ["NOTES"]}])), + form(json!([{"tables": ["Notes"]}])) + ); + // V8: allows no table, which is not the absence of the key. + assert_ne!(form(json!([{"tables": []}])), DelegatedCaveat::default()); + // V7: explicit false is the same as absent. + assert!(form(json!([{"readOnly": false}])).is_unrestricted()); + // V12: two empty entries restrict nothing. + assert_eq!( + classify(&wire(json!([{}, {}])), Reading::Stored), + Ok(AbilityCaveats::Unrestricted) + ); + } + + #[test] + fn refused_forms_are_unsupported_caveats() { + let long = "a".repeat(MAX_NAME_BYTES + 1); + for list in [ + json!([{"tabels": ["a"]}]), // R1 + json!([{"tables": ["a"], "extra": 1}]), // R1 + json!([{"tables": "a"}]), // R2 + json!([{"columns": "a"}]), // R2 + json!([{"readOnly": "true"}]), // R2 + json!([{"readOnly": 1}]), // R2 + json!([{"readOnly": null}]), // R2 + json!([{"tables": [1]}]), // R2 + json!([{"tables": [null]}]), // R2 + json!([{"tables": [["a"]]}]), // R2 + json!([{"tables": [""]}]), // R3 + json!([{"tables": names(257)}]), // R4 + json!([{"columns": names(257)}]), // R4 + json!([{"tables": ["Notes.body"]}]), // R5 + json!([{"columns": ["Notes.body"]}]), // R5 + json!([{"tables": ["a"]}, {"tables": ["b"]}]), // R6 + json!([{"tables": ["a"]}, {}]), // R6 + json!([{}, {"readOnly": true}]), // R6 + json!([{"tables": ["a"]}, profile()]), // R6 + json!([{"mode": "constrained-statements", "readOnly": true, "tables": ["a"], + "statements": []}]), // R7 + json!([{"mode": "constrained-statements", "readOnly": true, "columns": ["a"], + "statements": []}]), // R7 + json!([1]), // R8 + json!(["x"]), // R8 + json!([null]), // R8 + json!([[]]), // R8 + json!([{"tables": [" a"]}]), // R10 + json!([{"tables": ["a "]}]), // R10 + json!([{"columns": ["a\n"]}]), // R10 + json!([{"columns": ["a\u{0}b"]}]), // R10 + json!([{"tables": [long]}]), // longer than the cap + json!([profile(), {}]), // a profile beside an empty entry + ] { + let reason = ingress(list.clone()); + assert!( + reason + .as_deref() + .is_some_and(|r| r.starts_with("tinycloud.sql/read caveat ")), + "{list} -> {reason:?}" + ); + } + } + + #[test] + fn a_refusal_never_carries_a_caveat_value() { + let reason = ingress(json!([{"tables": ["SecretTableName."]}])).unwrap(); + assert!(!reason.contains("SecretTableName"), "{reason}"); + } + + #[test] + fn only_ascii_letters_fold() { + // The Kelvin sign is accepted as a name and is not `k`. + assert_eq!(ingress(json!([{"tables": ["\u{212A}x"]}])), None); + assert_ne!( + form(json!([{"tables": ["\u{212A}x"]}])), + form(json!([{"tables": ["kx"]}])) + ); + assert_eq!(fold("\u{212A}X"), "\u{212A}x"); + assert_eq!(fold("KX"), "kx"); + } + + #[test] + fn with_the_setting_off_only_empty_caveats_and_profiles_are_accepted() { + let off = |list: Value| unsupported_sql_caveat_at_ingress(SQL, &wire(list), false); + for list in [ + json!([{"tables": ["Notes"]}]), + json!([{"columns": ["body"]}]), + json!([{"readOnly": true}]), + json!([{"tables": []}]), + json!([{}, {}]), + ] { + let reason = off(list.clone()).unwrap_or_else(|| panic!("{list} accepted")); + assert!( + reason.ends_with("is not a constrained-statements profile"), + "{reason}" + ); + } + for list in [json!([]), json!([{}]), json!([profile()])] { + assert_eq!(off(list), None); + } + // With the setting off a profile entry keeps the keys beside `mode`. + assert_eq!( + off(json!([{"mode": "constrained-statements", "readOnly": true, + "tables": ["a"], "statements": []}])), + None + ); + // DuckDB takes empty caveats only, with the setting on or off. + for flag in [false, true] { + let refused = unsupported_sql_caveat_at_ingress( + "tinycloud.duckdb/read", + &wire(json!([{"tables": ["a"]}])), + flag, + ); + assert!(refused.is_some_and(|r| r.contains("accepts empty caveats only"))); + assert_eq!( + unsupported_sql_caveat_at_ingress( + "tinycloud.duckdb/read", + &wire(json!([{}])), + flag + ), + None + ); + assert!(unsupported_sql_caveat_at_ingress( + "tinycloud.duckdb/read", + &wire(json!([profile()])), + flag + ) + .is_some()); + } + } + + #[test] + fn a_stored_profile_entry_ignores_keys_beside_mode() { + // A profile entry carrying `tables` beside `mode` is a profile; the + // setting-on admission check refuses the mixture. + let stored = wire(json!([{"mode": "constrained-statements", "readOnly": true, + "tables": ["a"], "statements": []}])); + assert_eq!( + classify(&stored, Reading::Stored), + Ok(AbilityCaveats::Profile) + ); + assert!(classify(&stored, Reading::Ingress).is_err()); + } + + #[test] + fn containment_is_per_axis_and_folds_case() { + let ok = |parent: Value, child: Value| form(parent).contains(&form(child)); + // S1 + assert_eq!( + ok( + json!([{"tables": ["Notes", "t2"]}]), + json!([{"tables": ["Notes"]}]) + ), + Ok(()) + ); + // S2 + assert!(ok( + json!([{"tables": ["Notes"]}]), + json!([{"tables": ["Notes", "plain"]}]) + ) + .is_err()); + // S3: an absent parent axis is unrestricted + assert_eq!( + ok(json!([{"readOnly": false}]), json!([{"tables": ["Notes"]}])), + Ok(()) + ); + // S4 + assert!(ok(json!([{"readOnly": true}]), json!([{"readOnly": false}])).is_err()); + assert!(ok(json!([{"readOnly": true}]), json!([{"tables": ["a"]}])).is_err()); + assert_eq!( + ok(json!([{"readOnly": false}]), json!([{"readOnly": true}])), + Ok(()) + ); + assert_eq!( + ok( + json!([{"tables": ["a"]}]), + json!([{"tables": ["a"], "readOnly": true}]) + ), + Ok(()) + ); + // S5 + assert!(ok( + json!([{"columns": ["id"]}]), + json!([{"columns": ["id", "body"]}]) + ) + .is_err()); + assert_eq!( + ok(json!([{"columns": ["id"]}]), json!([{"columns": ["id"]}])), + Ok(()) + ); + // S6: both sides fold + assert_eq!( + ok( + json!([{"tables": ["notes"]}]), + json!([{"tables": ["NOTES"]}]) + ), + Ok(()) + ); + assert_eq!( + ok( + json!([{"tables": ["NOTES"]}]), + json!([{"tables": ["notes"]}]) + ), + Ok(()) + ); + // S7: a child that drops a restricted axis widens + assert!(ok(json!([{"tables": ["a"]}]), json!([{"readOnly": true}])).is_err()); + assert!(ok(json!([{"columns": ["a"]}]), json!([{"tables": ["a"]}])).is_err()); + // S8 + assert!(ok(json!([{"tables": ["Notes"]}]), json!([{"readOnly": true}])).is_err()); + // an empty table list is a restriction, narrower than any non-empty list + assert_eq!( + ok(json!([{"tables": ["a"]}]), json!([{"tables": []}])), + Ok(()) + ); + assert!(ok(json!([{"tables": []}]), json!([{"tables": ["a"]}])).is_err()); + } + + fn row(ability: &str, resource: &str, caveats: Caveats) -> crate::models::abilities::Model { + crate::models::abilities::Model { + delegation: crate::hash::hash(ability.as_bytes()), + resource: resource.parse().expect("a resource"), + ability: ability.to_string().try_into().expect("an ability"), + caveats, + } + } + + const DB: &str = + "tinycloud:pkh:eip155:1:0x0000000000000000000000000000000000000001:default/sql/db"; + + fn invoked(ability: &str) -> Vec { + vec![crate::util::Capability { + resource: DB.parse().expect("a resource"), + ability: ability.to_string().try_into().expect("an ability"), + caveats: Caveats::default(), + }] + } + + fn effective( + rows: &[crate::models::abilities::Model], + ) -> Result { + chain_caveat_of_rows(rows, &invoked(SQL)) + } + + #[test] + fn the_effective_caveat_is_the_intersection_over_the_chain() { + let hop = |list: Value| row("tinycloud.sql/*", DB, wire(list)); + // E1 + let chain = effective(&[ + hop(json!([{"tables": ["Notes", "t2"]}])), + hop(json!([{"tables": ["Notes"]}])), + ]) + .unwrap(); + assert_eq!(chain.form, form(json!([{"tables": ["Notes"]}]))); + assert!(chain.has_form && !chain.has_profile); + // E2: a hop without a caveat contributes nothing; so does a hop that + // grants another ability or another resource. + let chain = effective(&[ + hop(json!([{}])), + hop(json!([{"tables": ["Notes"]}])), + row("tinycloud.sql/admin", DB, wire(json!([{"tables": ["other"]}]))), + row("tinycloud.kv/get", DB, wire(json!([{"tables": ["other"]}]))), + row( + "tinycloud.sql/*", + "tinycloud:pkh:eip155:1:0x0000000000000000000000000000000000000001:default/sql/elsewhere", + wire(json!([{"tables": ["other"]}])), + ), + ]) + .unwrap(); + assert_eq!(chain.form, form(json!([{"tables": ["Notes"]}]))); + // E3: readOnly is an OR over the hops + let chain = effective(&[ + hop(json!([{"readOnly": true}])), + hop(json!([{"tables": ["a"], "readOnly": false}])), + ]) + .unwrap(); + assert_eq!( + chain.form, + form(json!([{"tables": ["a"], "readOnly": true}])) + ); + // E4: a hop wider than its parent can only be narrowed + let chain = effective(&[ + hop(json!([{"tables": ["Notes"]}])), + hop(json!([{"tables": ["Notes", "plain"]}])), + hop(json!([{"tables": ["plain", "t2"]}])), + ]) + .unwrap(); + assert_eq!(chain.form, form(json!([{"tables": []}]))); + let chain = effective(&[ + hop(json!([{"tables": ["Notes"]}])), + hop(json!([{"tables": ["Notes", "plain"]}])), + hop(json!([{}])), + ]) + .unwrap(); + assert_eq!(chain.form, form(json!([{"tables": ["Notes"]}]))); + // A chain with no restriction. + let chain = effective(&[hop(json!([{}])), hop(json!([{}, {}]))]).unwrap(); + assert_eq!(chain, ChainCaveat::default()); + } + + #[test] + fn an_unparseable_hop_anywhere_fails_the_chain() { + let hop = |list: Value| row("tinycloud.sql/*", DB, wire(list)); + for bad in [ + json!([{"tables": "Notes"}]), + json!([{"tabels": ["a"]}]), + json!([{"tables": ["a"]}, {"tables": ["b"]}]), + json!(["x"]), + ] { + for rows in [ + vec![hop(bad.clone()), hop(json!([{"tables": ["Notes"]}]))], + vec![hop(json!([{"tables": ["Notes"]}])), hop(bad.clone())], + ] { + assert!( + matches!(effective(&rows), Err(ChainCaveatError::Unevaluable)), + "{bad}" + ); + } + } + // An unparseable row for an ability the invocation does not hold is + // not evaluated. + assert!(effective(&[ + row("tinycloud.kv/get", DB, wire(json!(["x"]))), + hop(json!([{"tables": ["Notes"]}])) + ]) + .is_ok()); + } + + #[test] + fn a_profile_on_the_chain_is_reported_beside_a_form() { + let hop = |list: Value| row("tinycloud.sql/*", DB, wire(list)); + let chain = effective(&[hop(json!([profile()])), hop(json!([{"tables": ["a"]}]))]).unwrap(); + assert!(chain.has_profile && chain.has_form); + } + + #[test] + fn the_invocation_facts_only_narrow() { + // E6 + let chain = form(json!([{"tables": ["Notes"]}])); + let facts = SqlCaveats { + tables: Some(vec!["notes".into(), "Plain".into()]), + ..SqlCaveats::default() + }; + let merged = chain.narrowed_by_facts(Some(facts)).unwrap(); + assert_eq!(merged.tables, Some(vec!["notes".to_string()])); + // Facts cannot lift a restriction on another axis, and a chain with + // no restriction takes the facts as they are. + let facts = SqlCaveats { + columns: Some(vec!["Body".into()]), + read_only: Some(true), + ..SqlCaveats::default() + }; + let merged = chain.narrowed_by_facts(Some(facts.clone())).unwrap(); + assert_eq!(merged.tables, Some(vec!["notes".to_string()])); + assert_eq!(merged.columns, Some(vec!["body".to_string()])); + assert_eq!(merged.read_only, Some(true)); + let merged = DelegatedCaveat::default() + .narrowed_by_facts(Some(facts)) + .unwrap(); + assert_eq!(merged.tables, None); + // Nothing anywhere: no caveat at all. + assert!(DelegatedCaveat::default().narrowed_by_facts(None).is_none()); + // `readOnly: false` in the facts does not lift a chain's `readOnly`. + let read_only = form(json!([{"readOnly": true}])); + let merged = read_only + .narrowed_by_facts(Some(SqlCaveats { + read_only: Some(false), + ..SqlCaveats::default() + })) + .unwrap(); + assert_eq!(merged.read_only, Some(true)); + } +} diff --git a/tinycloud-core/src/sql/mod.rs b/tinycloud-core/src/sql/mod.rs index ffd71102..c6794394 100644 --- a/tinycloud-core/src/sql/mod.rs +++ b/tinycloud-core/src/sql/mod.rs @@ -2,6 +2,7 @@ pub mod authority; pub mod authorizer; pub mod caveats; pub mod database; +pub mod delegated_caveats; pub mod parser; pub mod service; pub mod storage; @@ -12,8 +13,11 @@ pub use authority::{DdlPolicy, SqlAuthority}; pub use caveats::SqlCaveats; pub use service::{SqlService, SqlWaitBounds}; pub use types::{ - BatchResponse, ExecuteResponse, QueryResponse, SqlBusyStage, SqlError, SqlExecutionResult, - SqlRequest, SqlResponse, SqlValue, + BatchResponse, CaveatArm, ExecuteResponse, QueryResponse, SqlBusyStage, SqlError, + SqlExecutionResult, SqlRequest, SqlResponse, SqlValue, }; pub mod publication; + +#[cfg(test)] +mod delegated_authorizer_tests; diff --git a/tinycloud-core/src/sql/parser.rs b/tinycloud-core/src/sql/parser.rs index 47092a12..fcfef904 100644 --- a/tinycloud-core/src/sql/parser.rs +++ b/tinycloud-core/src/sql/parser.rs @@ -167,8 +167,9 @@ pub fn validate_sql_authorized( return Err(SqlError::ReadOnlyViolation); } - // Validate caveats - if let Some(caveats) = caveats { + // Validate caveats. Under the delegated-caveat rules (TC-1791) the + // authorizer is the one place they are decided, and it names the arm. + if let Some(caveats) = caveats.as_ref().filter(|_| !authority.delegated_caveats()) { if caveats.read_only.unwrap_or(false) && !is_read_only { return Err(SqlError::ReadOnlyViolation); } diff --git a/tinycloud-core/src/sql/service.rs b/tinycloud-core/src/sql/service.rs index d3c8eff2..4c2f09b0 100644 --- a/tinycloud-core/src/sql/service.rs +++ b/tinycloud-core/src/sql/service.rs @@ -5026,4 +5026,202 @@ mod tests { .is_empty()); } } + + /// TC-1791: a request a delegated caveat refuses is refused whole. Nothing + /// of it is kept, no repository call is made, the answer is the fixed + /// `sql_caveat_violation: ` text, and the abilities still decide first. + #[tokio::test] + async fn delegated_caveat_refusals_are_whole_free_and_carry_only_the_arm() { + use crate::sql::CaveatArm; + for memory_threshold in [u64::MAX, 0] { + let repo = artifact_repository().await; + let calls = Arc::new(std::sync::Mutex::new(Vec::new())); + let cache = TempDir::new().unwrap(); + let space = test_space_id("sql-delegated-caveats"); + let service = SqlService::new( + cache.path().to_string_lossy().into_owned(), + memory_threshold, + Arc::new(CallCountingRepository { + inner: repo, + calls: calls.clone(), + }), + ); + for sql in [ + "CREATE TABLE plain (a, b)", + "CREATE TABLE Notes (id INTEGER PRIMARY KEY, body TEXT, secret TEXT)", + "INSERT INTO plain (a) VALUES (0)", + ] { + service + .execute( + &space, + "main", + SqlRequest::Execute { + schema: None, + sql: sql.into(), + params: Vec::new(), + }, + None, + "tinycloud.sql/admin".into(), + ) + .await + .unwrap(); + } + let handle = service.handle(&space, "main").await.unwrap(); + let before = handle.checkpoint().await.unwrap(); + let run = |request: SqlRequest, caveats: SqlCaveats, held: &[&str]| { + service.execute_authorized( + &space, + "main", + request, + Some(caveats), + SqlAuthority::new(held.iter().copied()).with_delegated_caveats(true), + ) + }; + let tables = |names: &[&str]| SqlCaveats { + tables: Some(names.iter().map(|n| n.to_string()).collect()), + ..SqlCaveats::default() + }; + let write = ["tinycloud.sql/write"]; + let untouched = |calls: &Arc>>| { + assert!( + calls.lock().unwrap().is_empty(), + "a refused request costs no repository (Postgres) round trip: {:?}", + calls.lock().unwrap() + ); + }; + + // The refused statement last, first, and alone. + for statements in [ + [ + "INSERT INTO plain (a) VALUES (1)", + "INSERT INTO Notes (id) VALUES (9)", + ], + [ + "INSERT INTO Notes (id) VALUES (9)", + "INSERT INTO plain (a) VALUES (1)", + ], + ] { + calls.lock().unwrap().clear(); + let refused = run(atomic_batch(&statements), tables(&["plain"]), &write).await; + let error = refused.unwrap_err(); + assert!(matches!(error, SqlError::CaveatViolation(CaveatArm::Table))); + // Only the arm: no SQL text, table or column name. + assert_eq!(error.to_string(), "sql_caveat_violation: table"); + untouched(&calls); + assert_eq!(handle.checkpoint().await.unwrap(), before); + assert_eq!( + rows_of(&service, &space, "SELECT a FROM plain").await, + vec![vec![SqlValue::Integer(0)]] + ); + } + + // Schema statements and a write under `readOnly`: refused whole, and + // the table the schema statement would create does not exist. + calls.lock().unwrap().clear(); + let read_only = SqlCaveats { + read_only: Some(true), + ..SqlCaveats::default() + }; + for request in [ + SqlRequest::Execute { + schema: Some(vec!["CREATE TABLE z (a)".into()]), + sql: "INSERT INTO plain (a) VALUES (1)".into(), + params: Vec::new(), + }, + SqlRequest::Execute { + schema: None, + sql: "INSERT INTO plain (a) VALUES (1)".into(), + params: Vec::new(), + }, + ] { + let error = run( + request, + read_only.clone(), + &["tinycloud.sql/write", "tinycloud.sql/schema"], + ) + .await + .unwrap_err(); + assert_eq!(error.to_string(), "sql_caveat_violation: write"); + } + untouched(&calls); + assert_eq!(handle.checkpoint().await.unwrap(), before); + assert!(rows_of( + &service, + &space, + "SELECT name FROM sqlite_master WHERE name = 'z'" + ) + .await + .is_empty()); + + // The column arm, also on a full space. + let columns = SqlCaveats { + tables: Some(vec!["Notes".into()]), + columns: Some(vec!["body".into()]), + ..SqlCaveats::default() + }; + let query = |sql: &str| SqlRequest::Query { + sql: sql.into(), + params: Vec::new(), + max_rows: None, + max_bytes: None, + }; + let error = run(query("SELECT secret FROM Notes"), columns.clone(), &write) + .await + .unwrap_err(); + assert_eq!(error.to_string(), "sql_caveat_violation: column"); + let error = service + .execute_without_growth_authorized( + &space, + "main", + query("SELECT secret FROM Notes"), + Some(columns.clone()), + SqlAuthority::new(write).with_delegated_caveats(true), + ) + .await + .unwrap_err(); + assert_eq!(error.to_string(), "sql_caveat_violation: column"); + untouched(&calls); + + // A read-tier grant may not write whatever the caveat lists: the + // ability refuses first. The read follows the caveat. + let error = run( + SqlRequest::Execute { + schema: None, + sql: "INSERT INTO Notes (id) VALUES (9)".into(), + params: Vec::new(), + }, + tables(&["Notes"]), + &["tinycloud.sql/read"], + ) + .await + .unwrap_err(); + assert!(matches!(error, SqlError::ReadOnlyViolation), "{error:?}"); + assert!(run( + query("SELECT id FROM Notes"), + tables(&["Notes"]), + &["tinycloud.sql/read"] + ) + .await + .is_ok()); + untouched(&calls); + + // A request the caveat allows is kept and persisted once. + run( + SqlRequest::Execute { + schema: None, + sql: "INSERT INTO plain (a) VALUES (2)".into(), + params: Vec::new(), + }, + tables(&["PLAIN"]), + &write, + ) + .await + .unwrap(); + assert_eq!(calls.lock().unwrap().len(), 1); + assert_eq!( + rows_of(&service, &space, "SELECT a FROM plain ORDER BY a").await, + vec![vec![SqlValue::Integer(0)], vec![SqlValue::Integer(2)]] + ); + } + } } diff --git a/tinycloud-core/src/sql/types.rs b/tinycloud-core/src/sql/types.rs index 794a3892..95621072 100644 --- a/tinycloud-core/src/sql/types.rs +++ b/tinycloud-core/src/sql/types.rs @@ -238,8 +238,34 @@ pub struct SqlExecutionResult { pub write_targets: Vec, } +/// Which part of a delegated caveat refused a request (TC-1791). `Write`, +/// `Table` and `Column` come from the authorizer; `Chain` means the +/// delegation chain's caveats could not be evaluated. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CaveatArm { + Column, + Table, + Write, + Chain, +} + +impl CaveatArm { + pub fn as_str(self) -> &'static str { + match self { + Self::Column => "column", + Self::Table => "table", + Self::Write => "write", + Self::Chain => "chain", + } + } +} + #[derive(Debug, thiserror::Error)] pub enum SqlError { + /// A delegated caveat refused the request. The text is fixed: it names the + /// arm and nothing from the request. + #[error("sql_caveat_violation: {}", .0.as_str())] + CaveatViolation(CaveatArm), #[error("SQLite error: {0}")] Sqlite(String), #[error("Permission denied: {0}")] diff --git a/tinycloud-core/src/util.rs b/tinycloud-core/src/util.rs index 1ea67f67..ac5b8220 100644 --- a/tinycloud-core/src/util.rs +++ b/tinycloud-core/src/util.rs @@ -325,3 +325,45 @@ impl TryFrom for RevocationInfo { } } } + +#[cfg(test)] +mod tests { + use super::*; + + const RESOURCE: &str = + "tinycloud:pkh:eip155:1:0x0000000000000000000000000000000000000001:default/sql/main"; + + /// What the node sees as `cap.caveats` for one ability's caveat list. + fn seen_by_the_node(list: &str) -> Result { + let capabilities: UcanCapabilities = serde_json::from_str(&format!( + r#"{{"{RESOURCE}": {{"tinycloud.sql/read": {list}}}}}"# + ))?; + let extracted = extract_ucan_caps(&capabilities); + assert_eq!(extracted.len(), 1); + Ok(serde_json::to_value(&extracted[0].caveats).unwrap()) + } + + /// TC-1791: how a caveat list reaches `cap.caveats`, the form every + /// delegated-caveat rule reads. + #[test] + fn a_caveat_list_reaches_the_node_as_entries_keyed_by_index() { + use serde_json::json; + // One empty entry is collapsed to no caveat at all. + assert_eq!(seen_by_the_node("[{}]").unwrap(), json!({})); + // Two empty entries are kept, as two empty objects. + assert_eq!( + seen_by_the_node("[{}, {}]").unwrap(), + json!({"0": {}, "1": {}}) + ); + assert_eq!( + seen_by_the_node(r#"[{"tables": ["Notes"]}]"#).unwrap(), + json!({"0": {"tables": ["Notes"]}}) + ); + assert_eq!( + seen_by_the_node(r#"[{"tables": ["a"]}, {"readOnly": true}]"#).unwrap(), + json!({"0": {"tables": ["a"]}, "1": {"readOnly": true}}) + ); + // An empty list does not parse at all: no delegation can carry it. + assert!(seen_by_the_node("[]").is_err()); + } +} diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index f35773a2..0dd87414 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -57,6 +57,8 @@ pub struct Config { pub sql_store: SqlStoreConfig, #[serde(default)] pub encryption_network: EncryptionNetworkConfig, + #[serde(default)] + pub sql: SqlConfig, } /// Release gates for the encryption network routes. `named_keys_v1` @@ -68,6 +70,18 @@ pub struct EncryptionNetworkConfig { pub named_keys_v1: bool, } +/// `[global.sql]` release gate for delegated SQL caveats (TC-1791). +/// `delegated_caveats_enforced` admits the `tables` / `columns` / `readOnly` +/// caveat form on `tinycloud.sql/*` delegations, enforces it from the +/// delegation chain when a request runs, and advertises +/// `sql-delegated-caveats-v1` in `/info`. Off: such a delegation is refused as +/// an unsupported caveat and nothing is advertised. +#[derive(Serialize, Deserialize, Debug, Default, Clone, Hash, PartialEq, Eq)] +pub struct SqlConfig { + #[serde(default)] + pub delegated_caveats_enforced: bool, +} + /// Production exact-email composition. The capability remains unavailable /// until every trust/key/dependency field is configured; there are no fake /// adapters or development credentials in this configuration surface. @@ -1897,6 +1911,15 @@ mod tests { assert!(config.named_keys_v1); } + #[test] + fn sql_delegated_caveats_is_off_by_default_and_parses_from_toml() { + assert!(!Config::default().sql.delegated_caveats_enforced); + let config: SqlConfig = toml::from_str("").unwrap(); + assert!(!config.delegated_caveats_enforced); + let config: SqlConfig = toml::from_str("delegated_caveats_enforced = true").unwrap(); + assert!(config.delegated_caveats_enforced); + } + #[test] fn sql_store_parses_from_toml_and_fills_defaults() { let config: SqlStoreConfig = toml::from_str( diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index de89ea4a..1f52b2d6 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -809,7 +809,8 @@ pub async fn app_with_control( Some(webhook_encryption.clone()), ) .await? - .with_sql_sizes(sql_sizes.clone()); + .with_sql_sizes(sql_sizes.clone()) + .with_delegated_sql_caveats(tinycloud_config.sql.delegated_caveats_enforced); tinycloud.set_read_audit_wait(Duration::from_millis( tinycloud_config.database.read_audit_wait_ms, )); diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index 5ba719af..2ae00a3e 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -58,7 +58,7 @@ use tinycloud_core::{ }, request_cancel, sea_orm::{ColumnTrait, DbErr, EntityTrait, QueryFilter, QueryOrder}, - sql::{SqlAuthority, SqlCaveats, SqlError, SqlRequest, SqlService}, + sql::{CaveatArm, SqlAuthority, SqlCaveats, SqlError, SqlRequest, SqlService}, storage::{HashBuffer, ImmutableReadStore, ImmutableStaging}, types::{Ability, DelegationQuery, DelegationQueryPage, Metadata, Resource}, util::{Capability, DelegationInfo, InvocationInfo, RevocationInfo}, @@ -325,6 +325,9 @@ fn build_info( if config.encryption_network.named_keys_v1 { features.push("encryption-named-keys-v1"); } + if config.sql.delegated_caveats_enforced { + features.push("sql-delegated-caveats-v1"); + } NodeInfo { protocol: tinycloud_auth::protocol::PROTOCOL_VERSION, version: env!("CARGO_PKG_VERSION").to_string(), @@ -2693,12 +2696,52 @@ async fn handle_sql_invoke( )?; let actor = admitted.invocation().0.invoker.clone(); + let sql_invoked: Vec = admitted + .invocation() + .0 + .capabilities + .iter() + .filter(|cap| { + matches!( + (&cap.resource, cap.ability.as_ref().as_ref()), + (Resource::TinyCloud(r), ability) + if r.service().as_str() == "sql" && ability.starts_with("tinycloud.sql/") + ) + }) + .cloned() + .collect(); // TC-409: `admitted` was verified exactly once at the /invoke admission // boundary; `verify_auth_admitted` uses the admitted core entry point so this // shared SQL/DuckDB authorization path does not re-run signature // verification a second time. let auth_result = verify_auth_admitted("server.sql.auth", admitted, tinycloud).await?; let constrained = resolve_sql_profile(own_profiles, tinycloud, &parent_cids).await?; + // TC-1791: with the node setting on, the effective caveat is the + // intersection over every stored delegation on the cited chain; the + // invocation's own `sqlCaveats` fact only narrows it further. + let chain_caveat = if config.sql.delegated_caveats_enforced { + let conn = tinycloud + .readable() + .await + .map_err(|e| (Status::InternalServerError, e.to_string()))?; + let roots: Vec = parent_cids + .iter() + .copied() + .map(tinycloud_core::hash::Hash::from) + .collect(); + let chain = + tinycloud_core::sql::delegated_caveats::chain_caveat(&conn, &roots, &sql_invoked) + .await + .map_err(chain_caveat_refusal)?; + if chain.has_form && (chain.has_profile || constrained.is_some()) { + return Err(caveat_violation(CaveatArm::Chain).into()); + } + chain + } else { + Default::default() + }; + // The delegated-caveat rules judge a request that no profile governs. + let delegated_mode = config.sql.delegated_caveats_enforced && constrained.is_none(); let body_start = Instant::now(); let body_result = read_json_body_limited( data, @@ -2718,7 +2761,8 @@ async fn handle_sql_invoke( // compatibility command. Statement authorization sees every SQL ability // the invocation holds, so a `[write, schema]` request can run both data // and schema-changing statements. - let authority = SqlAuthority::new(sql_caps.iter().map(|(_, _, held)| held.as_str())); + let authority = SqlAuthority::new(sql_caps.iter().map(|(_, _, held)| held.as_str())) + .with_delegated_caveats(delegated_mode); // N3 will resolve this logical identity through an explicit alias table. // Do not fall back to the legacy final-segment selector here. let space_id = space.to_string(); @@ -2744,6 +2788,29 @@ async fn handle_sql_invoke( sql_request }; + // Bind SQL service execution to the resolved profile when one applies. + // The invocation's `sqlCaveats` facts are a fallback used only when no + // profile applies; they never override a resolved profile. With the + // delegated-caveat setting on, the facts narrow the chain's caveat. + let exec_caveats: Option = match &constrained { + Some(c) => Some(constrained_caveat_to_sql_caveats(c)), + None if delegated_mode => chain_caveat.form.narrowed_by_facts(facts_caveats), + None => facts_caveats, + }; + + // Refuse a database export under a table or column restriction: the + // export is the whole file, not a statement the authorizer can judge. + if matches!(sql_request, SqlRequest::Export) && delegated_mode { + let arm = match &exec_caveats { + Some(c) if c.tables.is_some() => Some(CaveatArm::Table), + Some(c) if c.columns.is_some() => Some(CaveatArm::Column), + _ => None, + }; + if let Some(arm) = arm { + return Err(caveat_violation(arm).into()); + } + } + if matches!(sql_request, SqlRequest::Export) { let export_start = Instant::now(); let export_result = sql_service.export(space, &db_name).await; @@ -2756,14 +2823,6 @@ async fn handle_sql_invoke( return Ok(DataOut::One(InvOut(InvocationOutcome::SqlExport(data)))); } - // Bind SQL service execution to the resolved profile when one applies. - // The invocation's `sqlCaveats` facts are a fallback used only when no - // profile applies; they never override a resolved profile. - let exec_caveats: Option = match &constrained { - Some(c) => Some(constrained_caveat_to_sql_caveats(c)), - None => facts_caveats, - }; - // SQL storage gate — same limit resolution as the KV path. Reads never // 402. Under the limit, one-write overshoot is accepted: usage is only // known post-execute, so a write crossing the limit is admitted and the @@ -3314,7 +3373,7 @@ pub(crate) fn sql_error_to_status(err: &SqlError) -> Status { SqlError::StorageWouldGrow => Status::new(402), SqlError::InvalidStatement(_) => Status::BadRequest, SqlError::SchemaError(_) => Status::BadRequest, - SqlError::ReadOnlyViolation => Status::Forbidden, + SqlError::ReadOnlyViolation | SqlError::CaveatViolation(_) => Status::Forbidden, SqlError::ParseError(_) => Status::BadRequest, SqlError::Internal(_) => { // The only SQL 500 that names no database failure of its own: @@ -3328,6 +3387,26 @@ pub(crate) fn sql_error_to_status(err: &SqlError) -> Status { } } +/// The refusal of a request a delegated caveat does not allow. The body is the +/// fixed `sql_caveat_violation: ` text; it carries no SQL, table or +/// column name. +fn caveat_violation(arm: CaveatArm) -> (Status, String) { + let error = SqlError::CaveatViolation(arm); + (sql_error_to_status(&error), error.to_string()) +} + +fn chain_caveat_refusal( + error: tinycloud_core::sql::delegated_caveats::ChainCaveatError, +) -> (Status, String) { + use tinycloud_core::sql::delegated_caveats::ChainCaveatError; + match error { + ChainCaveatError::Db(error) => (Status::InternalServerError, error.to_string()), + ChainCaveatError::Unevaluable | ChainCaveatError::LimitExceeded => { + caveat_violation(CaveatArm::Chain) + } + } +} + /// Classify a DuckDB request as storage-growing for the storage quota gate. /// /// Mirrors `sql_request_is_write`: SQL-bearing variants are classified by @@ -10211,7 +10290,8 @@ mod tests { Either::B(storage), StaticSecret::new(vec![0u8; 32]).unwrap(), ) - .await?; + .await? + .with_delegated_sql_caveats(config.sql.delegated_caveats_enforced); let conn = db; space_model::ActiveModel { id: Set(SpaceIdWrap(space.clone())), @@ -11594,6 +11674,1125 @@ mod tests { Ok(()) } + /// TC-1791: `/info` advertises `sql-delegated-caveats-v1` only while the + /// setting is on. + #[tokio::test] + async fn sql_delegated_caveats_feature_is_advertised_only_behind_its_setting() -> Result<()> { + async fn features(config: Config) -> Result> { + let fixture = KvSyncFixture::new_configured(config, |rocket| rocket).await?; + let response = fixture.client.get("/info").dispatch().await; + assert_eq!(response.status(), rocket::http::Status::Ok); + let info: serde_json::Value = + serde_json::from_str(&response.into_string().await.unwrap_or_default())?; + Ok(info["features"] + .as_array() + .unwrap() + .iter() + .map(|feature| feature.as_str().unwrap().to_string()) + .collect()) + } + + let withheld = features(Config::default()).await?; + assert!( + !withheld.iter().any(|f| f.contains("sql-delegated")), + "{withheld:?}" + ); + assert!(withheld.iter().any(|f| f == "sql"), "{withheld:?}"); + + let mut on = Config::default(); + on.sql.delegated_caveats_enforced = true; + let advertised = features(on).await?; + assert!( + advertised.iter().any(|f| f == "sql-delegated-caveats-v1"), + "{advertised:?}" + ); + // Nothing else changes with the setting. + let mut expected = withheld; + expected.push("sql-delegated-caveats-v1".to_string()); + assert_eq!(advertised, expected); + Ok(()) + } + + /// The caveat list of one ability as the node stores it: `[{}]` is + /// collapsed to no caveat, any other list is keyed by entry index. + fn tc1791_caveats(list: serde_json::Value) -> tinycloud_core::types::Caveats { + let entries = list.as_array().expect("a list"); + if entries.len() == 1 && entries[0] == serde_json::json!({}) { + return Default::default(); + } + tinycloud_core::types::Caveats( + entries + .iter() + .enumerate() + .map(|(index, entry)| (index.to_string(), entry.clone())) + .collect(), + ) + } + + fn tc1791_on() -> Config { + let mut config = Config::default(); + config.sql.delegated_caveats_enforced = true; + config + } + + impl KvSyncFixture { + async fn ensure_actor(&self, did: &str) -> Result<()> { + use tinycloud_core::models::actor; + use tinycloud_core::sea_orm::{ActiveModelTrait, ActiveValue::Set}; + if actor::Entity::find_by_id(did.to_string()) + .one(&self.conn) + .await? + .is_none() + { + actor::ActiveModel { + id: Set(did.to_string()), + } + .insert(&self.conn) + .await?; + } + Ok(()) + } + + /// A stored delegation `delegator -> delegatee` holding one SQL + /// ability row per `(ability, path, caveat list)`, inserted without an + /// admission check. + async fn grant_sql_rows( + &self, + label: &str, + delegator: &str, + delegatee: &str, + rows: &[(&str, &str, serde_json::Value)], + parents: &[tinycloud_core::hash::Hash], + ) -> Result { + use tinycloud_core::models::{abilities as ability_model, delegation}; + use tinycloud_core::relationships::parent_delegations; + use tinycloud_core::sea_orm::ActiveModelTrait; + use tinycloud_core::sea_orm::ActiveValue::Set; + + self.ensure_actor(delegator).await?; + self.ensure_actor(delegatee).await?; + let id = tinycloud_core::hash::hash(format!("tc1791-{label}").as_bytes()); + delegation::ActiveModel { + id: Set(id), + delegator: Set(delegator.to_string()), + delegatee: Set(delegatee.to_string()), + expiry: Set(None), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(label.as_bytes().to_vec()), + } + .insert(&self.conn) + .await?; + for (ability, path, list) in rows { + ability_model::ActiveModel { + delegation: Set(id), + resource: Set(Resource::TinyCloud(self.resource_in( + &self.space, + "sql", + path, + )?)), + ability: Set(Ability::try_from(ability.to_string()).unwrap()), + caveats: Set(tc1791_caveats(list.clone())), + } + .insert(&self.conn) + .await?; + } + for parent in parents { + parent_delegations::ActiveModel { + parent: Set(*parent), + child: Set(id), + } + .insert(&self.conn) + .await?; + } + Ok(id) + } + + async fn grant_sql( + &self, + label: &str, + delegator: &str, + delegatee: &str, + ability: &str, + list: serde_json::Value, + parents: &[tinycloud_core::hash::Hash], + ) -> Result { + self.grant_sql_rows( + label, + delegator, + delegatee, + &[(ability, "db", list)], + parents, + ) + .await + } + + /// `POST /delegate` of one SQL ability on `db` with `list` as its caveat + /// list, signed by `signer` and citing `proof`. + #[allow(clippy::too_many_arguments)] + async fn delegate_sql( + &self, + signer: &JWK, + signer_vm: &str, + audience: &str, + ability: &str, + list: serde_json::Value, + proof: &[tinycloud_core::hash::Hash], + nonce: &str, + ) -> Result { + use rocket::http::Header; + use tinycloud_auth::{ + ssi::{ + claims::jwt::NumericDate, + dids::{DIDBuf, DIDURLBuf}, + ucan::Payload, + }, + ucan_capabilities_object::Capabilities, + }; + + let entries: Vec> = + serde_json::from_value(list)?; + let mut capabilities = Capabilities::::new(); + capabilities.with_actions( + self.resource_in(&self.space, "sql", "db")?.as_uri(), + std::iter::once((ability.parse::()?, entries)), + ); + let token = Payload { + issuer: signer_vm.parse::()?, + audience: audience.parse::()?, + not_before: None, + expiration: NumericDate::try_from_seconds(4_102_444_800.0)?, + nonce: Some(format!("urn:uuid:tc1791-{nonce}")), + facts: Some(Vec::::new()), + proof: proof.iter().map(|id| id.to_cid(0x55)).collect(), + attenuation: capabilities, + } + .sign(signer.get_algorithm().unwrap_or_default(), signer)? + .encode()?; + let response = self + .client + .post("/delegate") + .header(Header::new("Authorization", token)) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + let json = serde_json::from_str(&body).unwrap_or(serde_json::Value::Null); + Ok(SyncReply { status, body, json }) + } + + fn delegation_id(reply: &SyncReply) -> Result { + let cid = reply.json["cid"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("no cid in {}", reply.body))? + .parse::()?; + Ok(tinycloud_core::hash::Hash::from(cid)) + } + + /// A SQL invocation on `db` of `abilities`, signed by `signer` and + /// citing `proof`, as one JSON request. + async fn sql_as( + &self, + signer: &JWK, + signer_vm: &str, + abilities: &[&str], + proof: &[tinycloud_core::hash::Hash], + request: SqlRequest, + ) -> Result { + self.sql_as_with_facts(signer, signer_vm, abilities, proof, Vec::new(), request) + .await + } + + /// [`Self::sql_as`] with `facts` in the invocation payload. + async fn sql_as_with_facts( + &self, + signer: &JWK, + signer_vm: &str, + abilities: &[&str], + proof: &[tinycloud_core::hash::Hash], + facts: Vec, + request: SqlRequest, + ) -> Result { + use tinycloud_auth::ssi::{dids::DIDURLBuf, ucan::Payload}; + use tinycloud_auth::ucan_capabilities_object::Capabilities; + let uri = self.resource_in(&self.space, "sql", "db")?.as_uri(); + let mut capabilities = Capabilities::new(); + for action in abilities { + capabilities.with_action( + uri.clone(), + action.parse::()?, + [std::collections::BTreeMap::::new()], + ); + } + let header = Payload { + issuer: signer_vm.parse::()?, + audience: Self::did_of(signer_vm).parse::()?, + not_before: None, + expiration: test_invocation_expiration(), + nonce: Some(self.next_nonce()), + facts: Some(facts), + proof: proof.iter().map(|id| id.to_cid(0x55)).collect(), + attenuation: capabilities, + } + .sign(signer.get_algorithm().unwrap_or_default(), signer)? + .encode()?; + self.invoke_json(header, &request).await + } + + async fn sql_device( + &self, + proof: tinycloud_core::hash::Hash, + request: SqlRequest, + ) -> Result { + self.sql_as( + &self.device, + &self.device_vm, + &["tinycloud.sql/write"], + &[proof], + request, + ) + .await + } + } + + fn tc1791_query(sql: &str) -> SqlRequest { + SqlRequest::Query { + sql: sql.into(), + params: Vec::new(), + max_rows: None, + max_bytes: None, + } + } + + fn tc1791_execute(sql: &str) -> SqlRequest { + SqlRequest::Execute { + sql: sql.into(), + params: Vec::new(), + schema: None, + } + } + + fn tc1791_batch(statements: &[&str]) -> SqlRequest { + SqlRequest::Batch { + statements: statements + .iter() + .map(|sql| tinycloud_core::sql::types::SqlStatement { + sql: (*sql).into(), + params: Vec::new(), + }) + .collect(), + } + } + + /// The tables the delegated-caveat tests read and write, created by the + /// owner under no caveat. + async fn tc1791_seed(fixture: &KvSyncFixture) -> Result<()> { + let reply = fixture + .owner_sql( + "db", + SqlRequest::Execute { + schema: Some(vec![ + "CREATE TABLE Notes(id INTEGER PRIMARY KEY, body TEXT, secret TEXT)".into(), + "CREATE TABLE t2(id INTEGER PRIMARY KEY, x TEXT)".into(), + "CREATE TABLE plain(a, b)".into(), + "INSERT INTO Notes VALUES (1, 'a', 's'), (2, 'b', 't')".into(), + ]), + sql: "INSERT INTO plain VALUES (1, 2)".into(), + params: Vec::new(), + }, + ) + .await?; + anyhow::ensure!( + reply.status == rocket::http::Status::Ok, + "seed: {}", + reply.body + ); + Ok(()) + } + + async fn tc1791_count(fixture: &KvSyncFixture, table: &str) -> Result { + let reply = fixture + .owner_sql("db", tc1791_query(&format!("SELECT count(*) FROM {table}"))) + .await?; + anyhow::ensure!( + reply.status == rocket::http::Status::Ok, + "count: {}", + reply.body + ); + reply.json["rows"][0][0] + .as_i64() + .ok_or_else(|| anyhow::anyhow!("count not an integer: {}", reply.body)) + } + + /// TC-1791: with the setting on, `/delegate` admits the delegated form and + /// refuses a hop that widens it; `/invoke` enforces what the chain says. + #[tokio::test] + async fn delegated_sql_caveats_are_admitted_and_enforced_when_the_setting_is_on() -> Result<()> + { + use rocket::http::Status; + use serde_json::json; + let fixture = KvSyncFixture::new_configured(tc1791_on(), |rocket| rocket).await?; + tc1791_seed(&fixture).await?; + let third = JWK::generate_ed25519()?; + let third_vm = tc341_verification_method(&third)?; + let third_did = KvSyncFixture::did_of(&third_vm); + let write = "tinycloud.sql/write"; + + // The owner grants the device `Notes` and `t2`. + let root = fixture + .delegate_sql( + &fixture.owner, + &fixture.owner_vm, + &fixture.device_did(), + write, + json!([{"tables": ["Notes", "t2"]}]), + &[], + "root", + ) + .await?; + assert_eq!(root.status, Status::Ok, "{}", root.body); + let root_id = KvSyncFixture::delegation_id(&root)?; + + // A hop that widens, or drops the restricted axis, is refused. + for (label, list) in [ + ("wide", json!([{"tables": ["Notes", "plain"]}])), + ("lost", json!([{"readOnly": true}])), + ("none", json!([{}])), + ] { + let refused = fixture + .delegate_sql( + &fixture.device, + &fixture.device_vm, + &third_did, + write, + list, + &[root_id], + label, + ) + .await?; + assert_eq!( + refused.status, + Status::Unauthorized, + "{label}: {}", + refused.body + ); + assert!( + refused.body.contains("child-caveats-not-subset-of-parent"), + "{label}: {}", + refused.body + ); + } + + // A narrower hop (names fold) is admitted, and the third party runs + // under it. + let child = fixture + .delegate_sql( + &fixture.device, + &fixture.device_vm, + &third_did, + write, + json!([{"tables": ["notes"]}]), + &[root_id], + "narrow", + ) + .await?; + assert_eq!(child.status, Status::Ok, "{}", child.body); + let child_id = KvSyncFixture::delegation_id(&child)?; + let held = [write]; + let proof = [child_id]; + let third_sql = + |request: SqlRequest| fixture.sql_as(&third, &third_vm, &held, &proof, request); + + let read = third_sql(tc1791_query("SELECT body FROM NOTES ORDER BY id")).await?; + assert_eq!(read.status, Status::Ok, "{}", read.body); + assert_eq!( + read.json["rows"].as_array().map(Vec::len), + Some(2), + "{}", + read.body + ); + // `count(*)` names no column and `Notes` is listed. + let count = third_sql(tc1791_query("SELECT count(*) FROM Notes")).await?; + assert_eq!(count.status, Status::Ok, "{}", count.body); + + // `t2` is on the root grant but not on the narrower hop. + let refused = third_sql(tc1791_query("SELECT * FROM t2")).await?; + assert_eq!(refused.status, Status::Forbidden, "{}", refused.body); + assert_eq!(refused.body, "sql_caveat_violation: table"); + + // A batch with a refused statement is refused whole. + let refused = third_sql(tc1791_batch(&[ + "INSERT INTO Notes (id, body) VALUES (5, 'x')", + "INSERT INTO plain (a) VALUES (7)", + ])) + .await?; + assert_eq!(refused.status, Status::Forbidden, "{}", refused.body); + assert_eq!(refused.body, "sql_caveat_violation: table"); + assert_eq!(tc1791_count(&fixture, "Notes").await?, 2); + assert_eq!(tc1791_count(&fixture, "plain").await?, 1); + + // A write the chain allows runs. + let written = third_sql(tc1791_execute( + "INSERT INTO Notes (id, body) VALUES (5, 'x')", + )) + .await?; + assert_eq!(written.status, Status::Ok, "{}", written.body); + assert_eq!(tc1791_count(&fixture, "Notes").await?, 3); + + // The whole database file is not a statement the chain can judge. + let export = third_sql(SqlRequest::Export).await?; + assert_eq!(export.status, Status::Forbidden, "{}", export.body); + assert_eq!(export.body, "sql_caveat_violation: table"); + + // The invoker's own facts and the chain both bind; a refused request + // names only the arm. + assert!(!refused.body.contains("plain") && !refused.body.contains("INSERT")); + Ok(()) + } + + /// TC-1791: with the setting off, the delegated form is refused and nothing + /// is stored; `/info` does not advertise it. + #[tokio::test] + async fn delegated_sql_caveats_are_refused_when_the_setting_is_off() -> Result<()> { + use rocket::http::Status; + use serde_json::json; + use tinycloud_core::models::delegation as deleg_model; + use tinycloud_core::sea_orm::PaginatorTrait; + let fixture = KvSyncFixture::new_configured(Config::default(), |rocket| rocket).await?; + for (label, list) in [ + ("tables", json!([{"tables": ["Notes"]}])), + ("columns", json!([{"columns": ["body"]}])), + ("read-only", json!([{"readOnly": true}])), + ("two-empty", json!([{}, {}])), + ("empty-tables", json!([{"tables": []}])), + ] { + let refused = fixture + .delegate_sql( + &fixture.owner, + &fixture.owner_vm, + &fixture.device_did(), + "tinycloud.sql/read", + list, + &[], + label, + ) + .await?; + assert_eq!( + refused.status, + Status::Unauthorized, + "{label}: {}", + refused.body + ); + assert_eq!( + refused.body, + "unsupported-caveat: tinycloud.sql/read caveat 0 is not a constrained-statements profile", + "{label}" + ); + } + assert_eq!(deleg_model::Entity::find().count(&fixture.conn).await?, 0); + + // An empty caveat and a constrained-statements profile are admitted. + for (label, list) in [ + ("empty", json!([{}])), + ( + "profile", + json!([{"mode": "constrained-statements", "readOnly": true, + "statements": [{"name": "n", "sql": "SELECT 1"}]}]), + ), + ] { + let admitted = fixture + .delegate_sql( + &fixture.owner, + &fixture.owner_vm, + &fixture.device_did(), + "tinycloud.sql/read", + list, + &[], + label, + ) + .await?; + assert_eq!(admitted.status, Status::Ok, "{label}: {}", admitted.body); + } + Ok(()) + } + + /// TC-1791: with the setting on, two empty entries restrict nothing and are + /// admitted; two entries of which one is not empty are refused. + #[tokio::test] + async fn delegated_sql_caveat_list_shapes_through_delegate() -> Result<()> { + use rocket::http::Status; + use serde_json::json; + let fixture = KvSyncFixture::new_configured(tc1791_on(), |rocket| rocket).await?; + let mut results = Vec::new(); + for (label, list) in [ + ("two-empty", json!([{}, {}])), + ("one-form", json!([{"tables": ["a"]}])), + ("form-and-empty", json!([{"tables": ["a"]}, {}])), + ("two-forms", json!([{"tables": ["a"]}, {"tables": ["b"]}])), + ("unknown-key", json!([{"tabels": ["a"]}])), + ("dot", json!([{"tables": ["Notes.body"]}])), + ] { + let reply = fixture + .delegate_sql( + &fixture.owner, + &fixture.owner_vm, + &fixture.device_did(), + "tinycloud.sql/read", + list, + &[], + label, + ) + .await?; + results.push(( + label, + reply.status, + reply.body.starts_with("unsupported-caveat"), + )); + } + assert_eq!( + results, + vec![ + ("two-empty", Status::Ok, false), + ("one-form", Status::Ok, false), + ("form-and-empty", Status::Unauthorized, true), + ("two-forms", Status::Unauthorized, true), + ("unknown-key", Status::Unauthorized, true), + ("dot", Status::Unauthorized, true), + ] + ); + Ok(()) + } + + /// TC-1791 (E3, E4, E5, F6): the effective caveat comes from every stored + /// hop of the cited chain, however each was admitted: a hop wider than its + /// parent is narrowed by it, `readOnly` is an OR, and an unparseable hop + /// fails the request. + #[tokio::test] + async fn the_stored_chain_narrows_the_request_even_when_a_hop_is_wider() -> Result<()> { + use rocket::http::Status; + use serde_json::json; + let fixture = KvSyncFixture::new_configured(tc1791_on(), |rocket| rocket).await?; + tc1791_seed(&fixture).await?; + let owner = fixture.space.did().to_string(); + let device = fixture.device_did(); + let all = "tinycloud.sql/*"; + + // Three hops; the middle one lists `plain`, which its parent does not. + let parent = fixture + .grant_sql( + "p", + &owner, + "did:key:mid1", + all, + json!([{"tables": ["Notes", "t2"]}]), + &[], + ) + .await?; + let wider = fixture + .grant_sql( + "c", + "did:key:mid1", + "did:key:mid2", + all, + json!([{"tables": ["Notes", "plain"]}]), + &[parent], + ) + .await?; + let leaf = fixture + .grant_sql( + "l", + "did:key:mid2", + &device, + "tinycloud.sql/write", + json!([{}]), + &[wider], + ) + .await?; + let run = |sql: &'static str| fixture.sql_device(leaf, tc1791_query(sql)); + assert_eq!(run("SELECT body FROM Notes").await?.status, Status::Ok); + for sql in ["SELECT a FROM plain", "SELECT id FROM t2"] { + let refused = run(sql).await?; + assert_eq!(refused.status, Status::Forbidden, "{sql}: {}", refused.body); + assert_eq!(refused.body, "sql_caveat_violation: table", "{sql}"); + } + + // `readOnly` on one hop binds the request; a nested write is refused as + // a write, and nothing changes. + let read_only_hop = fixture + .grant_sql( + "ro-c", + "did:key:mid1", + "did:key:mid3", + all, + json!([{"readOnly": true}]), + &[parent], + ) + .await?; + let read_only_leaf = fixture + .grant_sql( + "ro-l", + "did:key:mid3", + &device, + "tinycloud.sql/write", + json!([{}]), + &[read_only_hop], + ) + .await?; + let run = |request: SqlRequest| fixture.sql_device(read_only_leaf, request); + assert_eq!( + run(tc1791_query("SELECT id FROM Notes")).await?.status, + Status::Ok + ); + for request in [ + tc1791_execute("INSERT INTO Notes (id) VALUES (9)"), + tc1791_query("WITH x AS (SELECT 1) INSERT INTO plain (a) SELECT * FROM x"), + tc1791_batch(&["DELETE FROM plain", "DELETE FROM Notes"]), + ] { + let refused = run(request).await?; + assert_eq!(refused.status, Status::Forbidden, "{}", refused.body); + assert_eq!(refused.body, "sql_caveat_violation: write"); + } + assert_eq!(tc1791_count(&fixture, "Notes").await?, 2); + assert_eq!(tc1791_count(&fixture, "plain").await?, 1); + + // An unparseable caveat on a row beside a good one: refused, fail closed. + let bad_beside_good = fixture + .grant_sql_rows( + "bad-beside-good", + "did:key:mid1", + &device, + &[ + (all, "db", json!([{"tables": "Notes"}])), + ("tinycloud.sql/write", "db", json!([{}])), + ], + &[parent], + ) + .await?; + let refused = fixture + .sql_device(bad_beside_good, tc1791_query("SELECT 1")) + .await?; + assert_eq!(refused.status, Status::Forbidden, "{}", refused.body); + assert_eq!(refused.body, "sql_caveat_violation: chain"); + + // An unparseable caveat on the only row that grants the ability: the + // row does not cover the invocation, as for any unsupported caveat. + let bad_alone = fixture + .grant_sql( + "bad-alone", + "did:key:mid1", + &device, + "tinycloud.sql/write", + json!([{"tabels": ["a"]}]), + &[parent], + ) + .await?; + let refused = fixture + .sql_device(bad_alone, tc1791_query("SELECT 1")) + .await?; + assert_eq!(refused.status, Status::Unauthorized, "{}", refused.body); + Ok(()) + } + + /// TC-1791: with the setting off, stored rows are judged as any non-profile + /// caveat is: a stored delegated form or a list of only empty entries does + /// not cover an invocation, and nothing runs under it; a row with an + /// unsupported caveat beside a good row for the ability is ignored. + #[tokio::test] + async fn stored_rows_are_judged_by_the_profile_rules_when_the_setting_is_off() -> Result<()> { + use rocket::http::Status; + use serde_json::json; + let fixture = KvSyncFixture::new_configured(Config::default(), |rocket| rocket).await?; + tc1791_seed(&fixture).await?; + let owner = fixture.space.did().to_string(); + let device = fixture.device_did(); + let write = "tinycloud.sql/write"; + + for (label, list) in [ + ("form", json!([{"tables": ["Notes"]}])), + ("read-only", json!([{"readOnly": true}])), + ("two-empty", json!([{}, {}])), + ] { + let row = fixture + .grant_sql(label, &owner, &device, write, list, &[]) + .await?; + let refused = fixture + .sql_device(row, tc1791_execute("INSERT INTO plain (a) VALUES (5)")) + .await?; + assert_eq!( + refused.status, + Status::Unauthorized, + "{label}: {}", + refused.body + ); + assert!( + refused + .body + .contains("invocation-caveats-not-subset-of-chain"), + "{label}: {}", + refused.body + ); + } + assert_eq!(tc1791_count(&fixture, "plain").await?, 1); + + let beside_good = fixture + .grant_sql_rows( + "bad-beside-good", + &owner, + &device, + &[ + ("tinycloud.sql/*", "db", json!([{"tables": "Notes"}])), + (write, "db", json!([{}])), + ], + &[], + ) + .await?; + let read = fixture + .sql_device(beside_good, tc1791_query("SELECT a FROM plain")) + .await?; + assert_eq!(read.status, Status::Ok, "{}", read.body); + Ok(()) + } + + /// TC-1791: under a `tables` or `columns` limit a PRAGMA schema report and + /// the database export are refused outright; a `readOnly` limit alone + /// leaves both available. + #[tokio::test] + async fn schema_reports_and_export_are_refused_under_a_table_or_column_limit() -> Result<()> { + use rocket::http::Status; + use serde_json::json; + let fixture = KvSyncFixture::new_configured(tc1791_on(), |rocket| rocket).await?; + tc1791_seed(&fixture).await?; + let owner = fixture.space.did().to_string(); + let device = fixture.device_did(); + let admin = "tinycloud.sql/admin"; + + for (label, list, export_arm) in [ + ("tables", json!([{"tables": ["Notes"]}]), "table"), + ("columns", json!([{"columns": ["body"]}]), "column"), + ] { + let row = fixture + .grant_sql(label, &owner, &device, admin, list, &[]) + .await?; + let held = [admin]; + let proof = [row]; + let run = |request: SqlRequest| { + fixture.sql_as(&fixture.device, &fixture.device_vm, &held, &proof, request) + }; + let pragma = run(tc1791_query("PRAGMA table_info(Notes)")).await?; + assert_eq!(pragma.status, Status::Forbidden, "{label}: {}", pragma.body); + assert_eq!(pragma.body, "sql_caveat_violation: table", "{label}"); + for sql in [ + "PRAGMA integrity_check", + "PRAGMA quick_check", + "PRAGMA foreign_key_check", + "PRAGMA foreign_key_check(t2)", + ] { + let check = run(tc1791_query(sql)).await?; + assert_eq!( + check.status, + Status::Forbidden, + "{label} {sql}: {}", + check.body + ); + assert_eq!(check.body, "sql_caveat_violation: table", "{label} {sql}"); + } + for sql in ["PRAGMA user_version = 5", "PRAGMA application_id = 7"] { + let set = run(tc1791_query(sql)).await?; + assert_eq!(set.status, Status::Forbidden, "{label} {sql}: {}", set.body); + assert_eq!(set.body, "sql_caveat_violation: write", "{label} {sql}"); + } + let export = run(SqlRequest::Export).await?; + assert_eq!(export.status, Status::Forbidden, "{label}: {}", export.body); + assert_eq!( + export.body, + format!("sql_caveat_violation: {export_arm}"), + "{label}" + ); + } + + let row = fixture + .grant_sql( + "read-only", + &owner, + &device, + admin, + json!([{"readOnly": true}]), + &[], + ) + .await?; + let held = [admin]; + let proof = [row]; + let run = |request: SqlRequest| { + fixture.sql_as(&fixture.device, &fixture.device_vm, &held, &proof, request) + }; + let pragma = run(tc1791_query("PRAGMA table_info(Notes)")).await?; + assert_eq!(pragma.status, Status::Ok, "{}", pragma.body); + let export = run(SqlRequest::Export).await?; + assert_eq!(export.status, Status::Ok, "{}", export.body); + let check = run(tc1791_query("PRAGMA integrity_check")).await?; + assert_eq!(check.status, Status::Ok, "{}", check.body); + let set = run(tc1791_query("PRAGMA user_version = 5")).await?; + assert_eq!(set.status, Status::Forbidden, "{}", set.body); + assert_eq!(set.body, "sql_caveat_violation: write"); + Ok(()) + } + + /// TC-1791: with a schema-conferring ability and `sqlite_master` listed, a + /// statement that changes a table still needs that table listed. + #[tokio::test] + async fn ddl_needs_the_table_it_names_to_be_listed() -> Result<()> { + use rocket::http::Status; + use serde_json::json; + let fixture = KvSyncFixture::new_configured(tc1791_on(), |rocket| rocket).await?; + tc1791_seed(&fixture).await?; + let owner = fixture.space.did().to_string(); + let device = fixture.device_did(); + let all = "tinycloud.sql/*"; + let row = fixture + .grant_sql( + "ddl", + &owner, + &device, + all, + json!([{"tables": ["Notes", "sqlite_master"]}]), + &[], + ) + .await?; + let held = [all]; + let proof = [row]; + let run = |sql: &str| { + fixture.sql_as( + &fixture.device, + &fixture.device_vm, + &held, + &proof, + tc1791_execute(sql), + ) + }; + for sql in [ + "ALTER TABLE t2 ADD COLUMN z", + "CREATE TABLE z (a)", + "CREATE INDEX t2x ON t2 (x)", + ] { + let refused = run(sql).await?; + assert_eq!(refused.status, Status::Forbidden, "{sql}: {}", refused.body); + assert_eq!(refused.body, "sql_caveat_violation: table", "{sql}"); + } + let allowed = run("ALTER TABLE Notes ADD COLUMN z").await?; + assert_eq!(allowed.status, Status::Ok, "{}", allowed.body); + let listing = fixture + .owner_sql( + "db", + tc1791_query("SELECT sql FROM sqlite_master WHERE name = 't2'"), + ) + .await?; + assert!( + !listing.body.contains(" z"), + "t2 was not changed: {}", + listing.body + ); + Ok(()) + } + + /// TC-1791: a real, non-empty invocation `sqlCaveats` fact only narrows + /// what the chain says; a request with a fact and nothing on the chain is + /// judged by the same rules (case folding, `count(*)` under `columns`). + #[tokio::test] + async fn an_invocation_fact_only_narrows_the_chain_caveat() -> Result<()> { + use rocket::http::Status; + use serde_json::json; + let fixture = KvSyncFixture::new_configured(tc1791_on(), |rocket| rocket).await?; + tc1791_seed(&fixture).await?; + let owner = fixture.space.did().to_string(); + let device = fixture.device_did(); + let write = "tinycloud.sql/write"; + let held = [write]; + let fact = |value: serde_json::Value| vec![json!({"sqlCaveats": value})]; + + // The chain lists `Notes` and `t2`; the fact lists `Notes` and `plain`. + let chain = fixture + .grant_sql( + "chain", + &owner, + &device, + write, + json!([{"tables": ["Notes", "t2"]}]), + &[], + ) + .await?; + let proof = [chain]; + let run = |facts: Vec, request: SqlRequest| { + fixture.sql_as_with_facts( + &fixture.device, + &fixture.device_vm, + &held, + &proof, + facts, + request, + ) + }; + let narrowed = fact(json!({"tables": ["notes", "plain"]})); + let ok = run(narrowed.clone(), tc1791_query("SELECT body FROM Notes")).await?; + assert_eq!(ok.status, Status::Ok, "{}", ok.body); + for sql in ["SELECT a FROM plain", "SELECT id FROM t2"] { + let refused = run(narrowed.clone(), tc1791_query(sql)).await?; + assert_eq!(refused.status, Status::Forbidden, "{sql}: {}", refused.body); + assert_eq!(refused.body, "sql_caveat_violation: table", "{sql}"); + } + + // A chain that is `readOnly` is not lifted by `readOnly: false`. + let read_only_chain = fixture + .grant_sql( + "ro", + &owner, + &device, + write, + json!([{"readOnly": true}]), + &[], + ) + .await?; + let proof = [read_only_chain]; + let run = |facts: Vec, request: SqlRequest| { + fixture.sql_as_with_facts( + &fixture.device, + &fixture.device_vm, + &held, + &proof, + facts, + request, + ) + }; + let lift = fact(json!({"readOnly": false})); + let refused = run( + lift.clone(), + tc1791_execute("INSERT INTO Notes (id) VALUES (9)"), + ) + .await?; + assert_eq!(refused.status, Status::Forbidden, "{}", refused.body); + assert_eq!(refused.body, "sql_caveat_violation: write"); + assert_eq!( + run(lift, tc1791_query("SELECT id FROM Notes")) + .await? + .status, + Status::Ok + ); + assert_eq!(tc1791_count(&fixture, "Notes").await?, 2); + + // Nothing on the chain: the fact alone is judged by the same rules. + let open = fixture + .grant_sql("open", &owner, &device, write, json!([{}]), &[]) + .await?; + let proof = [open]; + let run = |facts: Vec, request: SqlRequest| { + fixture.sql_as_with_facts( + &fixture.device, + &fixture.device_vm, + &held, + &proof, + facts, + request, + ) + }; + let columns = fact(json!({"columns": ["BODY"]})); + for sql in ["SELECT count(*) FROM Notes", "SELECT body FROM NOTES"] { + let allowed = run(columns.clone(), tc1791_query(sql)).await?; + assert_eq!(allowed.status, Status::Ok, "{sql}: {}", allowed.body); + } + let refused = run(columns, tc1791_query("SELECT secret FROM Notes")).await?; + assert_eq!(refused.status, Status::Forbidden, "{}", refused.body); + assert_eq!(refused.body, "sql_caveat_violation: column"); + Ok(()) + } + + /// TC-1791: a named statement (`ExecuteStatement`) runs under the chain + /// caveat like any other statement. + #[tokio::test] + async fn a_named_statement_runs_under_the_chain_caveat() -> Result<()> { + use rocket::http::Status; + use serde_json::json; + let fixture = KvSyncFixture::new_configured(tc1791_on(), |rocket| rocket).await?; + tc1791_seed(&fixture).await?; + let owner = fixture.space.did().to_string(); + let device = fixture.device_did(); + let write = "tinycloud.sql/write"; + let held = [write]; + let statements = vec![json!({"sqlCaveats": {"statements": [ + {"name": "get", "sql": "SELECT body FROM Notes ORDER BY id"}, + {"name": "leak", "sql": "SELECT a FROM plain"}, + {"name": "put", "sql": "INSERT INTO Notes (id, body) VALUES (7, 'n')"}, + ]}})]; + let named = |name: &str| SqlRequest::ExecuteStatement { + name: name.to_string(), + params: Vec::new(), + }; + + let chain = fixture + .grant_sql( + "chain", + &owner, + &device, + write, + json!([{"tables": ["Notes"]}]), + &[], + ) + .await?; + let proof = [chain]; + let run = |request: SqlRequest| { + fixture.sql_as_with_facts( + &fixture.device, + &fixture.device_vm, + &held, + &proof, + statements.clone(), + request, + ) + }; + let get = run(named("get")).await?; + assert_eq!(get.status, Status::Ok, "{}", get.body); + assert_eq!( + get.json["rows"].as_array().map(Vec::len), + Some(2), + "{}", + get.body + ); + let leak = run(named("leak")).await?; + assert_eq!(leak.status, Status::Forbidden, "{}", leak.body); + assert_eq!(leak.body, "sql_caveat_violation: table"); + let put = run(named("put")).await?; + assert_eq!(put.status, Status::Ok, "{}", put.body); + assert_eq!(tc1791_count(&fixture, "Notes").await?, 3); + + // Under a `readOnly` chain the same write statement is refused. + let read_only = fixture + .grant_sql( + "ro", + &owner, + &device, + write, + json!([{"readOnly": true}]), + &[], + ) + .await?; + let proof = [read_only]; + let run = |request: SqlRequest| { + fixture.sql_as_with_facts( + &fixture.device, + &fixture.device_vm, + &held, + &proof, + statements.clone(), + request, + ) + }; + let refused = run(named("put")).await?; + assert_eq!(refused.status, Status::Forbidden, "{}", refused.body); + assert_eq!(refused.body, "sql_caveat_violation: write"); + assert_eq!(run(named("get")).await?.status, Status::Ok); + assert_eq!(tc1791_count(&fixture, "Notes").await?, 3); + Ok(()) + } + /// TC-1317 review R2: a deadline shorter than the read-audit wait cuts a /// read waiting on its audit with `503 request_timeout` and /// `Retry-After: 1`, no data. That the wait is reported as diff --git a/tinycloud.toml b/tinycloud.toml index 28b4afd2..c61e0785 100644 --- a/tinycloud.toml +++ b/tinycloud.toml @@ -91,3 +91,12 @@ cors = true ## Keep off until named keys are live on OpenKey. Off = feature withheld. ## Env: TINYCLOUD_ENCRYPTION_NETWORK__NAMED_KEYS_V1 # named_keys_v1 = false + +[global.sql] + ## Accept the `tables` / `columns` / `readOnly` caveat form on + ## `tinycloud.sql/*` delegations, enforce it from the delegation chain on + ## every SQL request, and advertise `sql-delegated-caveats-v1` in GET /info. + ## Off = such a delegation is refused as an unsupported caveat and the + ## feature is withheld. + ## Env: TINYCLOUD_SQL__DELEGATED_CAVEATS_ENFORCED + # delegated_caveats_enforced = false