Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
- Encryption: key route errors start with a stable code: `network_not_found:`, `unauthorized:`, `key_not_found:` or `key_path_invalid:`. Request bodies that fail to parse on the key routes return 400 with the text `request body is malformed: <reason>`. Key paths are read from the request as sent, so a path with encoded or relative segments is refused rather than normalized. Network ids are accepted only in canonical form. Details: TC-1777.
- Encryption: named-key decrypt is served on `POST /invoke` (a key capability plus `keyPath` in the body); the direct network decrypt route refuses key resources and `keyPath`. A root-key unwrap failure on the direct route returns 403 `key_unwrap_failed`. Decrypt requests whose receiver key is not a valid 32-byte X25519 public key, or is a low-order point, are refused with 400 and the code `receiver_key_invalid`. Details: TC-1777.
- Configuration: adds `[encryption_network] named_keys_v1`, off by default. Details: TC-1777.
- SQL: a `tinycloud.sql/*` delegation can carry `tables`, `columns` and `readOnly` caveats (names match ASCII-case-insensitively; each hop must be at least as narrow as its parent). A request is judged by the intersection of these caveats over every stored delegation on the chain it cites, and a request they do not allow fails with 403 `sql_caveat_violation: write`, `table` or `column`, the whole request refused. Under a `tables` or `columns` limit the PRAGMA schema reports and checks (`sql_caveat_violation: table`), the PRAGMAs that set `user_version` or `application_id` (`sql_caveat_violation: write`) and the database export are refused; DDL on a table needs that table listed. A chain whose caveat cannot be read fails with `sql_caveat_violation: chain`. Behind `[global.sql] delegated_caveats_enforced` (env `TINYCLOUD_SQL__DELEGATED_CAVEATS_ENFORCED`), off by default; when on, `GET /info` lists `sql-delegated-caveats-v1`. With the setting off, these delegations are refused as an unsupported caveat. DuckDB takes empty caveats only. Docs: added `docs/sql-delegated-caveats.md`. Details: TC-1791.

## [1.21.9] - 2026-10-23

Expand Down
58 changes: 58 additions & 0 deletions docs/sql-delegated-caveats.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Delegated SQL caveats

A `tinycloud.sql/*` delegation can limit the tables and columns a holder may touch, and make the grant read-only. The node applies the limit to every SQL request that cites the delegation, however many hops sit between the owner and the holder.

The feature is behind `[global.sql] delegated_caveats_enforced` (env `TINYCLOUD_SQL__DELEGATED_CAVEATS_ENFORCED`), off by default. When on, `GET /info` lists `sql-delegated-caveats-v1` in `features`. When off, the form below is refused like any other unsupported caveat (`unsupported-caveat: ...`) and `/info` does not list the feature. DuckDB: `tinycloud.duckdb/*` takes empty caveats only.

## Grammar

The caveats of one ability are a list of objects. One entry may be:

| Entry | Meaning |
|---|---|
| `{}` | no restriction |
| `{"tables": [...], "columns": [...], "readOnly": true}` | the delegated form; keys are any subset of the three |
| a constrained-statements profile (`mode` key, or nested `constrained-statements`) | the request is bound to the profile's statements |

- `tables` and `columns` are lists of 1 to 256 names. A name is 1 to 128 bytes, has no `.` (so no `table.column`), no control character, and no leading or trailing whitespace. There are no wildcards: a name matches only itself.
- Names compare ASCII-case-insensitively, on both sides, everywhere (admission, containment, execution). Only the ASCII letters fold: the Kelvin sign (U+212A) is a different name from `k`.
- `readOnly` is a boolean. `false` is the same as absent. An absent key is no restriction on that axis; `"tables": []` is a restriction that allows no table.
- A list with a delegated entry has exactly that one entry. Entries of a list are alternatives, so a second entry, even an empty one, is refused. A list of only empty entries (`[{}]`, `[{}, {}]`) restricts nothing. An entry that has `mode` and also `tables` or `columns` is refused.
- Anything else (unknown key, wrong type, empty or over-long name, too many names) is refused at `/delegate` with `unsupported-caveat: <ability> caveat <index> <reason>`; the reason never repeats a name from the request.

## Chains

At `/delegate`, a child must be at least as narrow as the parent grant it relies on: its `tables` and `columns` are subsets of the parent's (an absent parent axis allows anything; a child that drops a restricted axis is refused), and `readOnly` may be turned on but not off. A widening hop is refused with `child-caveats-not-subset-of-parent`. A grant issued by the space owner is unconstrained.

At `/invoke`, the node computes the effective caveat from the stored delegations on the chain the invocation cites by CID, without relying on how each was admitted: `tables` and `columns` are the intersection over every hop that grants the invoked ability on the invoked resource, and `readOnly` is true if any hop sets it. A hop without the ability contributes nothing. A hop wider than its parent, for example one stored without the per-hop check, can only be narrowed by the intersection. If a matching hop's caveat cannot be read, the request is refused with 403 `sql_caveat_violation: chain`. The invocation's own `sqlCaveats` fact can narrow the result further and never widen it (`readOnly: false` does not lift a `readOnly` chain). A request that carries only an invocation fact, with no delegated form on its chain, is judged by the same rules: names fold, and `count(*)` is allowed under `columns`. Invocations carry no delegations of their own; a cited CID with no stored delegation is refused.

## Refusals

A request the effective caveat does not allow fails with 403 and the text `sql_caveat_violation: write`, `sql_caveat_violation: table` or `sql_caveat_violation: column`. The text names the arm and nothing else: no SQL, table or column name. When several arms apply, `write` wins over `table` over `column`. The abilities are checked first: a `read` grant that writes is refused by the ability check. A request with several statements is refused whole: nothing it did is kept. A named statement (`executeStatement`) is judged like any other statement.

## Additional refusals

Four kinds of refusal are not decided by one statement's table, column or write. All are 403.

| Request | Limit | Text |
|---|---|---|
| `{"action":"query","sql":"PRAGMA table_info(<table>)"}` (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('<table>')` 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 = <n>"}`, `application_id = <n>` (admin ability) | `tables` or `columns` (and `readOnly`) | `sql_caveat_violation: write` |
| `{"action":"export"}` | `tables` | `sql_caveat_violation: table` |
| `{"action":"export"}` | `columns` without `tables` | `sql_caveat_violation: column` |
| any request whose chain has an unreadable caveat on a hop that grants the invoked ability, or mixes a constrained-statements profile with the delegated form | any | `sql_caveat_violation: chain` |

Under `readOnly` alone, the PRAGMA schema reports, the checks and the export are allowed; a PRAGMA that sets a value is a write.

## What each limit does

- **`readOnly`** refuses every write and schema statement, a `PRAGMA` that sets a value (`PRAGMA user_version = 5`), and any statement the parser does not classify as read-only. Such a statement is refused at its first callback, whatever the action.
- **`tables`** lists every table a statement reads, writes or changes, including the tables reached through a view, a trigger or a foreign key. Writing to a table with a trigger or a foreign key needs the tables they touch listed too. Reading a view needs the view and its underlying tables. A statement that creates, alters or drops a table, an index, a view or a trigger needs that table or view listed, as well as `sqlite_master` (`sqlite_temp_master` for a temporary object) for the schema bookkeeping. `sqlite_master` and the `pragma_*` table functions are refused unless listed (see Additional refusals for the PRAGMA they report).
- **`columns`** lists every column a statement reads, plus the columns an `UPDATE` sets; `SELECT *` needs every column of the table listed. `INSERT` and `DELETE` name no column and are refused. A read that names no column, such as `count(*)` or `SELECT 1 FROM t`, is allowed when the table is allowed; it shows the row count and nothing else. A table without an integer primary key reads as the column `rowid`, which must be listed to be read.
- `REPLACE INTO` is judged as the `INSERT` it reports; under `columns` it is refused, under `readOnly` it is a write.
- The grant is checked against the names as created, folded: `SELECT body FROM NOTES` against `tables: ["notes"]` is allowed.

## Settings

`[global.sql] delegated_caveats_enforced = true` turns on admission, enforcement and the `/info` feature together. With it off the delegated form is refused at `/delegate` with `unsupported-caveat: <ability> caveat <index> 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.
83 changes: 69 additions & 14 deletions tinycloud-core/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ pub struct SpaceDatabase<C, B, S> {
secrets: S,
encryption: Option<ColumnEncryption>,
sql_sizes: SqlSizes,
delegated_sql_caveats: bool,
revocation_chain_locks: ChainLockRegistry,
kv_object_locks: KvObjectLockRegistry,
space_sequence_locks: SpaceSequenceLockRegistry,
Expand Down Expand Up @@ -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())),
Expand All @@ -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
Expand Down Expand Up @@ -653,7 +663,13 @@ where
invocation: &crate::util::InvocationInfo,
now: OffsetDateTime,
) -> Result<(), crate::models::invocation::Error> {
invocation::verify_and_authorize(&self.conn, invocation, now).await
invocation::verify_and_authorize_with_setting(
&self.conn,
invocation,
now,
self.delegated_sql_caveats,
)
.await
}

/// [`Self::authorize_invocation`] for an invocation whose signature and
Expand All @@ -665,7 +681,14 @@ where
invocation: &crate::AdmittedInvocation,
now: OffsetDateTime,
) -> Result<(), crate::models::invocation::Error> {
invocation::authorize_admitted(&self.conn, &invocation.invocation().0, now, None).await
invocation::authorize_admitted(
&self.conn,
&invocation.invocation().0,
now,
None,
self.delegated_sql_caveats,
)
.await
}

/// Check the persisted revocation state for cited delegations and every
Expand Down Expand Up @@ -738,9 +761,14 @@ where
query: &DelegationQuery,
) -> Result<DelegationQueryPage, AccountDelegationQueryError> {
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)?;
Expand Down Expand Up @@ -1652,6 +1680,7 @@ where
self.encryption.as_ref(),
None,
&[],
self.delegated_sql_caveats,
)
.await?;

Expand Down Expand Up @@ -1942,6 +1971,7 @@ where
self.encryption.as_ref(),
None,
&[],
self.delegated_sql_caveats,
)
.await
}
Expand Down Expand Up @@ -2416,6 +2446,7 @@ where
Some(&auth_graph),
// Taken on this `tx` right after BEGIN, above.
&sequence_spaces,
self.delegated_sql_caveats,
)
.await?;

Expand Down Expand Up @@ -2594,17 +2625,22 @@ where
// signed-time validity here rather than verifying the signature a
// second time.
match mode {
InvokeMode::Admitted => {
invocation::authorize_admitted(&self.conn, &invocation.0, now, sync_graph.as_ref())
.await
.map_err(TxError::<B, K>::from)?
}
InvokeMode::Admitted => invocation::authorize_admitted(
&self.conn,
&invocation.0,
now,
sync_graph.as_ref(),
self.delegated_sql_caveats,
)
.await
.map_err(TxError::<B, K>::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::<B, K>::from)?
Expand Down Expand Up @@ -3289,6 +3325,7 @@ async fn event_spaces<'a, C: ConnectionTrait>(
/// `sequence_locks_held` lists the spaces whose sequence lock the caller has
/// already taken on `db` (this same transaction) with
/// `kv_sync::lock_space_sequences`; `&[]` when it has taken none.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn transact<C: ConnectionTrait, S: StorageSetup, K: Secrets>(
db: &C,
store_setup: &S,
Expand All @@ -3297,6 +3334,7 @@ pub(crate) async fn transact<C: ConnectionTrait, S: StorageSetup, K: Secrets>(
encryption: Option<&ColumnEncryption>,
auth_graph: Option<&crate::auth_graph::AuthGraphSnapshot>,
sequence_locks_held: &[SpaceId],
delegated_sql_caveats: bool,
) -> Result<TransactResult, TxError<S, K>> {
// for each event, get the hash and the relevent space(s)
let event_hashes = events
Expand All @@ -3321,7 +3359,7 @@ pub(crate) async fn transact<C: ConnectionTrait, S: StorageSetup, K: Secrets>(
let mut registered: HashMap<Hash, delegation::DelegationRegistration> = 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);
}
}
Expand Down Expand Up @@ -3649,6 +3687,7 @@ pub(crate) async fn transact<C: ConnectionTrait, S: StorageSetup, K: Secrets>(
.collect(),
encryption,
auth_graph,
delegated_sql_caveats,
)
.await?;
}
Expand Down Expand Up @@ -3684,6 +3723,7 @@ pub(crate) async fn transact<C: ConnectionTrait, S: StorageSetup, K: Secrets>(
.collect(),
encryption,
auth_graph,
delegated_sql_caveats,
)
.await?;
}
Expand Down Expand Up @@ -3739,14 +3779,29 @@ pub(crate) async fn transact<C: ConnectionTrait, S: StorageSetup, K: Secrets>(
}
}
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?;
Expand Down
Loading
Loading