From 3854d11107bd0eb3a974d81720c21d21a561fdd9 Mon Sep 17 00:00:00 2001 From: tc1791a builder Date: Fri, 23 Oct 2026 17:31:11 +0000 Subject: [PATCH 1/6] sql: accept delegated table, column and read-only caveats on delegation (behind a setting) Adds [global.sql] delegated_caveats_enforced (off by default). When on, a tinycloud.sql/* delegation may carry tables / columns / readOnly caveats, each hop must be at least as narrow as its parent (ASCII-case-insensitive), and /info lists sql-delegated-caveats-v1. When off, such a delegation is refused as an unsupported caveat as before. Details: TC-1791 --- tinycloud-core/src/db.rs | 17 +- tinycloud-core/src/models/delegation.rs | 171 ++++- tinycloud-core/src/models/invocation.rs | 119 ++- tinycloud-core/src/sql/delegated_caveats.rs | 800 ++++++++++++++++++++ tinycloud-core/src/sql/mod.rs | 1 + tinycloud-core/src/util.rs | 42 + tinycloud-node-server/src/config.rs | 23 + tinycloud-node-server/src/lib.rs | 3 +- tinycloud-node-server/src/routes/mod.rs | 6 +- tinycloud.toml | 9 + 10 files changed, 1179 insertions(+), 12 deletions(-) create mode 100644 tinycloud-core/src/sql/delegated_caveats.rs diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 0cdc69d9..b65e142d 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 @@ -1652,6 +1662,7 @@ where self.encryption.as_ref(), None, &[], + self.delegated_sql_caveats, ) .await?; @@ -1942,6 +1953,7 @@ where self.encryption.as_ref(), None, &[], + self.delegated_sql_caveats, ) .await } @@ -2416,6 +2428,7 @@ where Some(&auth_graph), // Taken on this `tx` right after BEGIN, above. &sequence_spaces, + self.delegated_sql_caveats, ) .await?; @@ -3289,6 +3302,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 +3311,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 +3336,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); } } diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index 95aa6fa0..e159fed5 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,70 @@ 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 is unchanged. +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: the delegated form +/// is read whatever the admission setting is today. +pub(crate) fn unsupported_stored_sql_caveat( + ability: &str, + caveats: &crate::types::Caveats, +) -> Option { + use crate::sql::delegated_caveats::{classify, Reading}; + if 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 +587,11 @@ 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, + ) { Ok(()) => { authorized = true; break; @@ -775,13 +839,40 @@ 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) -> 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 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); + } + _ => {} + } + } let parent_sql = extract_sql_caveat(parent); let child_sql = extract_sql_caveat(child); @@ -1867,4 +1958,72 @@ 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)) + }; + 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 keep the earlier rule: 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)) + }; + assert!(kv(json!([{}, {}]), json!([{"tables": ["a"]}])).is_err()); + assert!(kv(json!([{}]), json!([{"tables": ["a"]}])).is_ok()); + } } diff --git a/tinycloud-core/src/models/invocation.rs b/tinycloud-core/src/models/invocation.rs index 0a8e4f99..38169787 100644 --- a/tinycloud-core/src/models/invocation.rs +++ b/tinycloud-core/src/models/invocation.rs @@ -498,7 +498,11 @@ 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, + ) { Ok(()) => { authorized = true; break; @@ -539,8 +543,13 @@ 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, + ) + .is_ok() + && crate::models::delegation::unsupported_stored_sql_caveat( ability.ability.as_ref().as_ref(), &ability.caveats, ) @@ -588,6 +597,38 @@ 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, +) -> Result<(), String> { + if 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 +1718,78 @@ 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)])) + } + + /// TC-1791: 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. + #[tokio::test] + async fn stored_delegated_form_covers_an_invocation_without_a_caveat() { + 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; + crate::models::invocation::authorize_holder( + &db, + "did:key:alias", + &[caveat_capability( + resource, + "tinycloud.sql/read", + Caveats::default(), + )], + &[parent], + OffsetDateTime::now_utc(), + ) + .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 = crate::models::invocation::authorize_holder( + &db, + "did:key:alias", + &[caveat_capability(resource, "tinycloud.sql/read", own)], + &[parent], + OffsetDateTime::now_utc(), + ) + .await + .unwrap_err(); + assert!( + matches!(&err, Error::InvalidInvocation(_)), + "unexpected rejection: {err:?}" + ); + } + } + + /// TC-1791: a stored grant of only empty entries imposes nothing. + #[tokio::test] + async fn stored_grant_of_only_empty_entries_covers_any_invocation() { + 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"), + ] { + crate::models::invocation::authorize_holder( + &db, + "did:key:alias", + &[caveat_capability(resource, "tinycloud.sql/read", own)], + &[parent], + OffsetDateTime::now_utc(), + ) + .await + .expect("empty entries restrict nothing"); + } + } + #[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/delegated_caveats.rs b/tinycloud-core/src/sql/delegated_caveats.rs new file mode 100644 index 00000000..07a9d9a0 --- /dev/null +++ b/tinycloud-core/src/sql/delegated_caveats.rs @@ -0,0 +1,800 @@ +//! 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): unchanged, +//! - 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 the_setting_off_keeps_every_earlier_refusal_and_acceptance() { + 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); + } + // The legacy reading ignores keys beside `mode`: unchanged. + 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_row_keeps_the_earlier_reading_of_a_profile() { + // Admitted before the delegated form existed: keys beside `mode` were + // ignored and stay ignored. + let legacy = wire(json!([{"mode": "constrained-statements", "readOnly": true, + "tables": ["a"], "statements": []}])); + assert_eq!( + classify(&legacy, Reading::Stored), + Ok(AbilityCaveats::Profile) + ); + assert!(classify(&legacy, 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..de06face 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; 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..099f0b0b 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -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(), @@ -10211,7 +10214,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())), 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 From a5ef5f5650e9d88fe93c7529ee3d8b13c6c389cb Mon Sep 17 00:00:00 2001 From: tc1791a builder Date: Fri, 23 Oct 2026 17:32:10 +0000 Subject: [PATCH 2/6] sql: judge table, column and read-only caveats in the authorizer and name the arm With the delegated-caveat rules on a request, the authorizer decides the abilities first, then the caveats: names compare ASCII-case-insensitively, a read that names no column is allowed when the table is, INSERT and DELETE are refused under columns, readOnly refuses writes, DDL and PRAGMA with a value. The arm that refused (write, then table, then column) is recorded and the request fails with 403 sql_caveat_violation: , whole. Details: TC-1791 --- tinycloud-core/src/sql/authority.rs | 74 +++- tinycloud-core/src/sql/authorizer.rs | 137 ++++++ tinycloud-core/src/sql/caveats.rs | 18 + tinycloud-core/src/sql/database.rs | 8 + .../src/sql/delegated_authorizer_tests.rs | 398 ++++++++++++++++++ tinycloud-core/src/sql/mod.rs | 7 +- tinycloud-core/src/sql/parser.rs | 5 +- tinycloud-core/src/sql/service.rs | 198 +++++++++ tinycloud-core/src/sql/types.rs | 26 ++ tinycloud-node-server/src/routes/mod.rs | 2 +- 10 files changed, 866 insertions(+), 7 deletions(-) create mode 100644 tinycloud-core/src/sql/delegated_authorizer_tests.rs 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..f344b373 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,147 @@ 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` refuses every write and DDL action, a PRAGMA with a value, and +/// any statement the parser did not classify as read-only. +/// - `tables`: every table read or written must be listed, including the +/// tables a trigger, a foreign key or a view reaches, and +/// `sqlite_master`. The PRAGMA schema reports 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. +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, + } => { + // 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. + if read_only + && pragma_value.is_some() + && !pragma_in(&READ_ONLY_PRAGMAS, pragma_name) + && !pragma_in(&ADMIN_CHECK_PRAGMAS, pragma_name) + { + Some(CaveatArm::Write) + } else if (caveats.tables.is_some() || caveats.columns.is_some()) + && pragma_in(&READ_ONLY_PRAGMAS, pragma_name) + { + Some(CaveatArm::Table) + } else { + None + } + } + AuthAction::CreateIndex { .. } + | AuthAction::CreateTable { .. } + | AuthAction::CreateTempIndex { .. } + | AuthAction::CreateTempTable { .. } + | AuthAction::CreateTempTrigger { .. } + | AuthAction::CreateTempView { .. } + | AuthAction::CreateTrigger { .. } + | AuthAction::CreateView { .. } + | AuthAction::DropIndex { .. } + | AuthAction::DropTable { .. } + | AuthAction::DropTempIndex { .. } + | AuthAction::DropTempTable { .. } + | AuthAction::DropTempTrigger { .. } + | AuthAction::DropTempView { .. } + | AuthAction::DropTrigger { .. } + | AuthAction::DropView { .. } + | AuthAction::AlterTable { .. } + | AuthAction::Reindex { .. } + | AuthAction::Analyze { .. } + | AuthAction::CreateVtable { .. } + | AuthAction::DropVtable { .. } => read_only.then_some(CaveatArm::Write), + _ => None, + } +} + +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..b0b87cb7 --- /dev/null +++ b/tinycloud-core/src/sql/delegated_authorizer_tests.rs @@ -0,0 +1,398 @@ +//! 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::Connection; + +use super::authority::SqlAuthority; +use super::authorizer::{create_authorizer_scoped, 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); +"; + +#[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) { + let conn = Connection::open_in_memory().expect("in-memory database"); + conn.execute_batch(FIXTURE).expect("fixture"); + // 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), +]; + +#[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_the_caveats_keep_their_earlier_reading() { + // 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); + } +} diff --git a/tinycloud-core/src/sql/mod.rs b/tinycloud-core/src/sql/mod.rs index de06face..c6794394 100644 --- a/tinycloud-core/src/sql/mod.rs +++ b/tinycloud-core/src/sql/mod.rs @@ -13,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-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index 099f0b0b..f43f381b 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -3317,7 +3317,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: From 251828c511c9b235d55ffd458f0055b6f3152e6b Mon Sep 17 00:00:00 2001 From: tc1791a builder Date: Fri, 23 Oct 2026 17:33:49 +0000 Subject: [PATCH 3/6] sql: judge a request by the caveats of every stored delegation on its chain With the setting on, a SQL request is run under the intersection of the delegated tables / columns / readOnly caveats over every stored delegation on the chain the invocation cites: a hop without the ability contributes nothing, readOnly is an OR, an unparseable hop fails the request with 403 sql_caveat_violation: chain. The invocation's sqlCaveats fact only narrows. The one effective value is what sql_request_is_write, the transaction decision and the authorizer see. A database export is refused under a tables or columns limit. Details: TC-1791 --- tinycloud-node-server/src/routes/mod.rs | 96 ++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 10 deletions(-) diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index f43f381b..c00922a6 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}, @@ -2696,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, @@ -2721,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(); @@ -2747,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; @@ -2759,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 @@ -3331,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 From 335ea117554d656b3b8f72b41cf833b58bc78731 Mon Sep 17 00:00:00 2001 From: tc1791a builder Date: Fri, 23 Oct 2026 17:34:04 +0000 Subject: [PATCH 4/6] sql: route-level tests, docs page and changelog for delegated caveats Details: TC-1791 --- CHANGELOG.md | 1 + docs/sql-delegated-caveats.md | 43 ++ tinycloud-node-server/src/routes/mod.rs | 707 ++++++++++++++++++++++++ 3 files changed, 751 insertions(+) create mode 100644 docs/sql-delegated-caveats.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 13f29e50..cae9d9f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,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. 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 before. DuckDB is unchanged. 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..0416c334 --- /dev/null +++ b/docs/sql-delegated-caveats.md @@ -0,0 +1,43 @@ +# 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 is unchanged: `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`) | unchanged | + +- `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). +- `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 admitted earlier, 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. 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 as before. A request with several statements is refused whole: nothing it did is kept. + +## 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. +- **`tables`** lists every table a statement reads or writes, 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. `sqlite_master` and the `pragma_*` table functions are refused unless listed; the `PRAGMA` schema reports (`table_info` and the like) and the database export are refused under any `tables` or `columns` limit. +- **`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. Turning it off again stops admitting the form; delegations already stored keep being enforced when a request cites them. diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index c00922a6..c115d862 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -11674,6 +11674,713 @@ 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 as if it had + /// been admitted earlier (no admission check runs). + 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 { + 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(Vec::::new()), + 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 exactly as + /// before and nothing is stored; `/info` does not advertise it. + #[tokio::test] + async fn delegated_sql_caveats_are_refused_as_before_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, as + // before. + 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-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 From bd03e327306dad75ffb53533069277e948688a3e Mon Sep 17 00:00:00 2001 From: tc1791a builder Date: Fri, 23 Oct 2026 17:47:19 +0000 Subject: [PATCH 5/6] sql: read delegated caveats on stored rows only with the setting on; document refusals With the setting off, invocation validation and per-hop containment call the code they called before; the setting is passed to them. Adds tests for stored rows with the setting off and for the PRAGMA schema report and export refusals, and lists the additional refusals in the docs page. Details: TC-1791 --- docs/sql-delegated-caveats.md | 17 ++- tinycloud-core/src/db.rs | 66 ++++++++-- tinycloud-core/src/models/delegation.rs | 40 ++++-- tinycloud-core/src/models/invocation.rs | 167 ++++++++++++++++++------ tinycloud-node-server/src/routes/mod.rs | 118 +++++++++++++++++ 5 files changed, 342 insertions(+), 66 deletions(-) diff --git a/docs/sql-delegated-caveats.md b/docs/sql-delegated-caveats.md index 0416c334..e5cce01d 100644 --- a/docs/sql-delegated-caveats.md +++ b/docs/sql-delegated-caveats.md @@ -30,14 +30,27 @@ At `/invoke`, the node computes the effective caveat from the stored delegations 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 as before. A request with several statements is refused whole: nothing it did is kept. +## Additional refusals + +Three 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":"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 and the export are allowed. + ## 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. -- **`tables`** lists every table a statement reads or writes, 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. `sqlite_master` and the `pragma_*` table functions are refused unless listed; the `PRAGMA` schema reports (`table_info` and the like) and the database export are refused under any `tables` or `columns` limit. +- **`tables`** lists every table a statement reads or writes, 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. `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. Turning it off again stops admitting the form; delegations already stored keep being enforced when a request cites them. +`[global.sql] delegated_caveats_enforced = true` turns on admission, enforcement and the `/info` feature together. With it off the node behaves as without this feature: 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 b65e142d..9912837a 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -663,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 @@ -675,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 @@ -748,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)?; @@ -2607,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)? @@ -3664,6 +3687,7 @@ pub(crate) async fn transact( .collect(), encryption, auth_graph, + delegated_sql_caveats, ) .await?; } @@ -3699,6 +3723,7 @@ pub(crate) async fn transact( .collect(), encryption, auth_graph, + delegated_sql_caveats, ) .await?; } @@ -3754,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 e159fed5..4a416495 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -301,14 +301,15 @@ pub(crate) fn unsupported_sql_caveat_at_ingress( unsupported_sql_caveat(ability, caveats) } -/// As [`unsupported_sql_caveat`], for a row already stored: the delegated form -/// is read whatever the admission setting is today. +/// 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 ability.starts_with("tinycloud.sql/") { + if delegated_sql_caveats && ability.starts_with("tinycloud.sql/") { return classify(caveats, Reading::Stored) .err() .map(|refusal| refusal.reason(ability)); @@ -591,6 +592,7 @@ async fn validate_with( c.ability.as_ref().as_ref(), &pc.caveats, &c.caveats, + delegated_sql_caveats, ) { Ok(()) => { authorized = true; @@ -839,7 +841,12 @@ 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(ability: &str, 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 @@ -848,7 +855,7 @@ fn caveats_contain_child(ability: &str, parent: &Caveats, child: &Caveats) -> Re } // TC-1791: delegated tables / columns / readOnly caveats on SQL // abilities. The parent decides which axes the child may not widen. - if ability.starts_with("tinycloud.sql/") { + if delegated_sql_caveats && ability.starts_with("tinycloud.sql/") { use crate::sql::delegated_caveats::{classify, AbilityCaveats, DelegatedCaveat, Reading}; match ( classify(parent, Reading::Stored), @@ -868,7 +875,12 @@ fn caveats_contain_child(ability: &str, parent: &Caveats, child: &Caveats) -> Re // 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); + return caveats_contain_child( + "", + &Caveats::default(), + child, + delegated_sql_caveats, + ); } _ => {} } @@ -1980,7 +1992,7 @@ mod tests { 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)) + caveats_contain_child(SQL, &wire(parent), &wire(child), true) }; assert!(ok( json!([{"tables": ["Notes", "t2"]}]), @@ -2021,9 +2033,21 @@ mod tests { assert!(ok(json!([profile]), json!([{"tables": ["a"]}])).is_err()); // Other services keep the earlier rule: 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)) + 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 38169787..3eb81915 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| { @@ -502,6 +550,7 @@ async fn validate_capabilities( c.ability.as_ref().as_ref(), &pc.caveats, &c.caveats, + delegated_sql_caveats, ) { Ok(()) => { authorized = true; @@ -547,11 +596,13 @@ async fn validate_capabilities( 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() }; @@ -607,8 +658,9 @@ fn ability_caveats_contain_child( ability: &str, parent: &Caveats, child: &Caveats, + delegated_sql_caveats: bool, ) -> Result<(), String> { - if ability.starts_with("tinycloud.sql/") { + if delegated_sql_caveats && ability.starts_with("tinycloud.sql/") { use crate::sql::delegated_caveats::{classify, AbilityCaveats, Reading}; match ( classify(parent, Reading::Stored), @@ -1722,52 +1774,85 @@ mod tests { Caveats(BTreeMap::from([("0".to_string(), list)])) } - /// TC-1791: 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. - #[tokio::test] - async fn stored_delegated_form_covers_an_invocation_without_a_caveat() { + 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"; - let stored = delegated_form_caveat(serde_json::json!({"tables": ["a"]})); - let (db, parent) = caveat_chain_db(resource, "tinycloud.sql/read", stored).await; - crate::models::invocation::authorize_holder( - &db, + crate::models::invocation::authorize_holder_with_setting( + db, "did:key:alias", - &[caveat_capability( - resource, - "tinycloud.sql/read", - Caveats::default(), - )], + &[caveat_capability(resource, "tinycloud.sql/read", own)], &[parent], OffsetDateTime::now_utc(), + setting, ) .await - .expect("a stored delegated form covers an invocation without a caveat"); + } + + 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 = crate::models::invocation::authorize_holder( - &db, - "did:key:alias", - &[caveat_capability(resource, "tinycloud.sql/read", own)], - &[parent], - OffsetDateTime::now_utc(), - ) - .await - .unwrap_err(); + 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: a stored grant of only empty entries imposes nothing. + /// TC-1791: with the setting on, a stored grant of only empty entries + /// imposes nothing; with it off it is refused as before. #[tokio::test] - async fn stored_grant_of_only_empty_entries_covers_any_invocation() { + 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!({})), @@ -1778,15 +1863,11 @@ mod tests { Caveats::default(), make_sql_constrained_caveat("get", "SELECT 1"), ] { - crate::models::invocation::authorize_holder( - &db, - "did:key:alias", - &[caveat_capability(resource, "tinycloud.sql/read", own)], - &[parent], - OffsetDateTime::now_utc(), - ) - .await - .expect("empty entries restrict nothing"); + 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:?}"); } } diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index c115d862..e688a7fa 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -12381,6 +12381,124 @@ mod tests { 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_as_before_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}"); + 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); + 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 From 714f10ffaf59703f0f431ce3d2d5b21e03d99ba4 Mon Sep 17 00:00:00 2001 From: tc1791a builder Date: Fri, 23 Oct 2026 18:20:41 +0000 Subject: [PATCH 6/6] sql: judge DDL against the table it names; refuse PRAGMA checks and header values under a limit Under a tables limit every action that names a table (CREATE, ALTER, DROP of a table, index, view or trigger, ANALYZE, virtual tables) needs that table listed. PRAGMA integrity_check, quick_check and foreign_key_check are refused under tables or columns (table arm); PRAGMA user_version and application_id with a value are refused (write arm). Adds the per-action audit test, route tests for an invocation fact, a named statement and DDL, non-ASCII name tests, and neutral wording. Details: TC-1791 --- CHANGELOG.md | 2 +- docs/sql-delegated-caveats.md | 22 +- tinycloud-core/src/models/delegation.rs | 4 +- tinycloud-core/src/models/invocation.rs | 2 +- tinycloud-core/src/sql/authorizer.rs | 105 ++-- .../src/sql/delegated_authorizer_tests.rs | 497 +++++++++++++++++- tinycloud-core/src/sql/delegated_caveats.rs | 30 +- tinycloud-node-server/src/routes/mod.rs | 312 ++++++++++- 8 files changed, 903 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa7e49ed..b71cf765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +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. 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 before. DuckDB is unchanged. Docs: added `docs/sql-delegated-caveats.md`. Details: TC-1791. +- 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 index e5cce01d..b592f953 100644 --- a/docs/sql-delegated-caveats.md +++ b/docs/sql-delegated-caveats.md @@ -2,7 +2,7 @@ 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 is unchanged: `tinycloud.duckdb/*` takes empty caveats only. +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 @@ -12,10 +12,10 @@ The caveats of one ability are a list of objects. One entry may be: |---|---| | `{}` | 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`) | unchanged | +| 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). +- 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. @@ -24,33 +24,35 @@ The caveats of one ability are a list of objects. One entry may be: 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 admitted earlier, 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. Invocations carry no delegations of their own; a cited CID with no stored delegation is refused. +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 as before. A request with several statements is refused whole: nothing it did is kept. +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 -Three kinds of refusal are not decided by one statement's table, column or write. All are 403. +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 and the export are allowed. +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. -- **`tables`** lists every table a statement reads or writes, 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. `sqlite_master` and the `pragma_*` table functions are refused unless listed (see Additional refusals for the PRAGMA they report). +- **`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 node behaves as without this feature: 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. +`[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/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index 4a416495..f23ee7bd 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -286,7 +286,7 @@ pub(crate) fn unsupported_sql_caveat( /// 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 is unchanged. +/// `tables` / `columns` / `readOnly` form; DuckDB takes empty caveats only. pub(crate) fn unsupported_sql_caveat_at_ingress( ability: &str, caveats: &crate::types::Caveats, @@ -2031,7 +2031,7 @@ mod tests { "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 keep the earlier rule: equal caveats or an empty parent. + // 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) }; diff --git a/tinycloud-core/src/models/invocation.rs b/tinycloud-core/src/models/invocation.rs index 3eb81915..99b51569 100644 --- a/tinycloud-core/src/models/invocation.rs +++ b/tinycloud-core/src/models/invocation.rs @@ -1850,7 +1850,7 @@ mod tests { } /// TC-1791: with the setting on, a stored grant of only empty entries - /// imposes nothing; with it off it is refused as before. + /// 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"; diff --git a/tinycloud-core/src/sql/authorizer.rs b/tinycloud-core/src/sql/authorizer.rs index f344b373..5562e1f4 100644 --- a/tinycloud-core/src/sql/authorizer.rs +++ b/tinycloud-core/src/sql/authorizer.rs @@ -298,15 +298,17 @@ pub fn create_authorizer_tracked( /// Which caveat arm refuses `ctx`, if one does (TC-1791). Precedence: write, /// then table, then column. Names compare ASCII-case-insensitively. /// -/// - `readOnly` refuses every write and DDL action, a PRAGMA with a value, and -/// any statement the parser did not classify as read-only. -/// - `tables`: every table read or written must be listed, including the -/// tables a trigger, a foreign key or a view reaches, and -/// `sqlite_master`. The PRAGMA schema reports are refused. +/// - `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. -fn delegated_denial( +pub(super) fn delegated_denial( caveats: &SqlCaveats, statement_is_read_only: bool, ctx: &AuthContext<'_>, @@ -352,46 +354,75 @@ fn delegated_denial( 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. - if read_only - && pragma_value.is_some() - && !pragma_in(&READ_ONLY_PRAGMAS, pragma_name) - && !pragma_in(&ADMIN_CHECK_PRAGMAS, pragma_name) - { + // 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) - } else if (caveats.tables.is_some() || caveats.columns.is_some()) - && pragma_in(&READ_ONLY_PRAGMAS, pragma_name) - { + // The schema reports and the checks list tables and rowids. + } else if restricted && (is_report || is_check) { Some(CaveatArm::Table) } else { None } } - AuthAction::CreateIndex { .. } - | AuthAction::CreateTable { .. } - | AuthAction::CreateTempIndex { .. } - | AuthAction::CreateTempTable { .. } - | AuthAction::CreateTempTrigger { .. } - | AuthAction::CreateTempView { .. } - | AuthAction::CreateTrigger { .. } - | AuthAction::CreateView { .. } - | AuthAction::DropIndex { .. } - | AuthAction::DropTable { .. } - | AuthAction::DropTempIndex { .. } - | AuthAction::DropTempTable { .. } - | AuthAction::DropTempTrigger { .. } - | AuthAction::DropTempView { .. } - | AuthAction::DropTrigger { .. } - | AuthAction::DropView { .. } - | AuthAction::AlterTable { .. } - | AuthAction::Reindex { .. } - | AuthAction::Analyze { .. } - | AuthAction::CreateVtable { .. } - | AuthAction::DropVtable { .. } => read_only.then_some(CaveatArm::Write), - _ => 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), } } diff --git a/tinycloud-core/src/sql/delegated_authorizer_tests.rs b/tinycloud-core/src/sql/delegated_authorizer_tests.rs index b0b87cb7..e4731613 100644 --- a/tinycloud-core/src/sql/delegated_authorizer_tests.rs +++ b/tinycloud-core/src/sql/delegated_authorizer_tests.rs @@ -3,10 +3,11 @@ //! 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, StatementScope}; +use super::authorizer::{create_authorizer_scoped, delegated_denial, StatementScope}; use super::caveats::SqlCaveats; use super::parser; use super::types::CaveatArm; @@ -23,6 +24,7 @@ CREATE TRIGGER tr AFTER INSERT ON Notes BEGIN INSERT INTO log VALUES(new.body); 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)] @@ -80,9 +82,20 @@ 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. @@ -273,6 +286,33 @@ const ROWS: &[Row] = &[ 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] @@ -338,7 +378,7 @@ fn the_abilities_decide_before_the_caveats() { } #[test] -fn without_the_delegated_rules_the_caveats_keep_their_earlier_reading() { +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"]); @@ -396,3 +436,456 @@ fn schema_reports_are_refused_under_a_table_or_column_restriction() { 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 index 07a9d9a0..e150c13b 100644 --- a/tinycloud-core/src/sql/delegated_caveats.rs +++ b/tinycloud-core/src/sql/delegated_caveats.rs @@ -6,7 +6,7 @@ //! //! - empty (`{}`): no restriction, //! - a constrained-statements profile (a `mode` key, or the nested -//! `{"constrained-statements": {...}}` form): unchanged, +//! `{"constrained-statements": {...}}` form): read by the profile rules, //! - the delegated form: keys exactly a subset of `tables`, `columns`, //! `readOnly`. //! @@ -507,7 +507,19 @@ mod tests { } #[test] - fn the_setting_off_keeps_every_earlier_refusal_and_acceptance() { + 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"]}]), @@ -525,7 +537,7 @@ mod tests { for list in [json!([]), json!([{}]), json!([profile()])] { assert_eq!(off(list), None); } - // The legacy reading ignores keys beside `mode`: unchanged. + // With the setting off a profile entry keeps the keys beside `mode`. assert_eq!( off(json!([{"mode": "constrained-statements", "readOnly": true, "tables": ["a"], "statements": []}])), @@ -557,16 +569,16 @@ mod tests { } #[test] - fn a_stored_row_keeps_the_earlier_reading_of_a_profile() { - // Admitted before the delegated form existed: keys beside `mode` were - // ignored and stay ignored. - let legacy = wire(json!([{"mode": "constrained-statements", "readOnly": true, + 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(&legacy, Reading::Stored), + classify(&stored, Reading::Stored), Ok(AbilityCaveats::Profile) ); - assert!(classify(&legacy, Reading::Ingress).is_err()); + assert!(classify(&stored, Reading::Ingress).is_err()); } #[test] diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index e688a7fa..2ae00a3e 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -11754,8 +11754,8 @@ mod tests { } /// A stored delegation `delegator -> delegatee` holding one SQL - /// ability row per `(ability, path, caveat list)`, inserted as if it had - /// been admitted earlier (no admission check runs). + /// ability row per `(ability, path, caveat list)`, inserted without an + /// admission check. async fn grant_sql_rows( &self, label: &str, @@ -11899,6 +11899,20 @@ mod tests { 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; @@ -11917,7 +11931,7 @@ mod tests { not_before: None, expiration: test_invocation_expiration(), nonce: Some(self.next_nonce()), - facts: Some(Vec::::new()), + facts: Some(facts), proof: proof.iter().map(|id| id.to_cid(0x55)).collect(), attenuation: capabilities, } @@ -12137,10 +12151,10 @@ mod tests { Ok(()) } - /// TC-1791: with the setting off, the delegated form is refused exactly as - /// before and nothing is stored; `/info` does not advertise it. + /// 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_as_before_when_the_setting_is_off() -> Result<()> { + 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; @@ -12178,8 +12192,7 @@ mod tests { } assert_eq!(deleg_model::Entity::find().count(&fixture.conn).await?, 0); - // An empty caveat and a constrained-statements profile are admitted, as - // before. + // An empty caveat and a constrained-statements profile are admitted. for (label, list) in [ ("empty", json!([{}])), ( @@ -12386,7 +12399,7 @@ mod tests { /// 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_as_before_when_the_setting_is_off() -> Result<()> { + 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?; @@ -12469,6 +12482,26 @@ mod tests { 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!( @@ -12497,8 +12530,269 @@ mod tests { 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