From 5fcb3313cc1e85399d35f38cfda289386335ec50 Mon Sep 17 00:00:00 2001 From: dryrun Date: Mon, 19 Oct 2026 13:05:47 +0000 Subject: [PATCH 1/3] feat(admin): read-only GET /admin/stats owner list (TC-1482) Adds GET /admin/stats, behind a dedicated NODE_STATS_READ_TOKEN, returning the distinct lowercase owner addresses that hold data beyond the automatic account space. One aggregate statement; a result past its bounds is a 507, never a partial 200. --- CHANGELOG.md | 1 + README.md | 41 ++ docs/releasing.md | 7 + tinycloud-core/src/data_owners.rs | 435 ++++++++++++ tinycloud-core/src/db.rs | 7 + tinycloud-core/src/lib.rs | 1 + tinycloud-node-server/src/lib.rs | 2 + .../src/routes/admin_stats.rs | 152 +++++ tinycloud-node-server/src/routes/mod.rs | 1 + tinycloud-node-server/tests/admin_stats.rs | 633 ++++++++++++++++++ .../tests/admin_stats_logs.rs | 235 +++++++ .../tests/postgres_round_trip_budget.rs | 130 ++++ 12 files changed, 1645 insertions(+) create mode 100644 tinycloud-core/src/data_owners.rs create mode 100644 tinycloud-node-server/src/routes/admin_stats.rs create mode 100644 tinycloud-node-server/tests/admin_stats.rs create mode 100644 tinycloud-node-server/tests/admin_stats_logs.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 847a44dc..b6d6d5d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changes +- Add `GET /admin/stats` (TC-1482), a read-only list of the lowercase wallet addresses that hold data on the node, for the scoreboard's protocol-user count. It is disabled (`404`, like an unknown route) unless the new `NODE_STATS_READ_TOKEN` is set; the token is separate from `TINYCLOUD_ADMIN_SECRET` (neither opens the other's routes). An owner counts when a space other than the automatic `account` space has a live `current_kv` row or any SQL/DuckDB `database_artifact` row. One SQL statement however many spaces exist, a result past 200,000 owners (or 400,000 spaces read) is a bare `507`, never a partial `200`. Neither the token nor any address is logged. The deployment workflow and compose files do not pass the variable yet. - Authorized reads no longer wait without limit for their audit record (TC-1317). Every read (KV get/list/metadata/sync, capabilities read, and the authorization step of SQL, DuckDB, space info, `/signed/kv` and `/encryption/networks`) waits for its invocation record to commit before its data is returned. That wait used to have no bound, and one serial queue served every account. - **Bounded wait.** A read waits at most `database.read_audit_wait_ms` (20 s) and is then answered `503 read_audit_busy: the read audit is backlogged; retry the request` with `Retry-After: 5`, without its data. The node refuses to start with `read_audit_wait_ms = 0`, which would answer every read busy. A batch that fails with a transient database error is now `503 unavailable_retryable` instead of 500; other batch failures stay 500, and none of them is reported as 401 any more. - **The invariant is unchanged and tested:** no read's data is returned unless its audit record has committed. diff --git a/README.md b/README.md index abae749e..307971d8 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ The SDK checks this endpoint during sign-in and requires an exact protocol versi | `POST` | `/delegate` | Yes | Create capability delegations | | `GET` | `/peer/generate/` | No | Generate space host key pair | | `GET` | `/healthz` | No | Health check | +| `GET` | `/admin/stats` | `NODE_STATS_READ_TOKEN` bearer | Distinct owner addresses that hold data (read-only; disabled unless the token is set) | ### Storage usage and full-space writes @@ -110,6 +111,45 @@ so the service bearer is sent only on the configured billing request. A `3xx` answer uses the existing quota fallback policy, with the existing 3-second total and 2-second connect deadlines. +### Admin stats (`GET /admin/stats`) + +Read-only list of the wallet addresses that hold data on this node, for the +scoreboard's protocol-user count (TC-1482). It is **disabled** (answers `404`, +exactly like an unknown route) unless `NODE_STATS_READ_TOKEN` is set to a +non-blank value that differs from `TINYCLOUD_ADMIN_SECRET`. Send it as +`Authorization: Bearer `; a missing or wrong credential +is a bare `401`. The token is separate from the admin secret: the admin secret +does not open this route and this token opens no other `/admin/*` route. Use a +random value of at least 32 bytes, kept in the secret store; the node never +logs it or any address. + +```json +{ "owners": ["0x…", "0x…"], "count": 2, "generatedAt": "2026-10-19T12:00:00Z" } +``` + +- **What is counted.** An owner is listed when a space they own, other than the + automatic `account` registry space, holds data: at least one live + (`deleted = false`) `current_kv` row, **or** at least one SQL/DuckDB + `database_artifact` row. Artifact rows are never deleted, so a database that + was created and later emptied or dropped still counts. The `account` space + never counts, whatever it holds, so an owner whose only space is `account` + is excluded. This is stricter than "spaces with `usage_bytes > 0`" + (`/admin/usage`), which counts the `account` space's bytes. +- **Owner.** Parsed from `tinycloud:pkh:eip155::0x
:`, + lowercased and de-duplicated across spaces and chains. Spaces whose owner is + not an `eip155` address (a `did:key` owner) are skipped. `owners` is sorted. +- **Cost.** One SQL statement (one round trip) however many spaces exist; no + per-space `store_size` call. For each space it makes one primary-key probe of + `current_kv`, plus one pass over `database_artifact`'s space column. +- **Bound, never a partial list.** The consumer makes no partial use of a + response, so the route never answers a shorter list with `200`. More than + 200,000 owners (or more than 400,000 data-holding spaces to read, which + bounds memory) is a bare `507`. Every entry is exactly `0x` followed by 40 + lowercase hex digits; a space whose owner is not such an `eip155` address is + left out. +- **Nothing else** about spaces, keys, sizes or timing is returned, and the + response is `Cache-Control: no-store`. + ### Signed KV URLs `POST /signed/kv` accepts a normal TinyCloud invocation whose authorized capabilities include a `tinycloud.kv/get` capability that can be attenuated to the requested `{space, path}`. The invocation may include other capabilities. The JSON body is: @@ -201,6 +241,7 @@ The following common options are available: | spaces.allowlist | TINYCLOUD_SPACES_ALLOWLIST | Set the URL of an allowlist service for gating the creation of Space Peers | | telemetry.enabled | TINYCLOUD_TELEMETRY__ENABLED | Enable Prometheus latency metrics on the configured Prometheus port | | (n/a) | ROCKET_MAX_BLOCKING | Set Rocket's blocking-thread-pool cap (default 512). Note the `ROCKET_` prefix, not `TINYCLOUD_` — Rocket builds its tokio runtime from its own Figment before TinyCloud's config is loaded, so `TINYCLOUD_MAX_BLOCKING` is silently ignored for this setting | +| (n/a) | NODE_STATS_READ_TOKEN | Bearer token for the read-only `GET /admin/stats` (TC-1482). Unset, blank, or equal to `TINYCLOUD_ADMIN_SECRET` disables the route (`404`). Separate from `TINYCLOUD_ADMIN_SECRET`, which it does not replace and which does not open it. Secret: set it in the deployment secret store, never in `tinycloud.toml` or a committed env file. See [Admin stats](#admin-stats-get-adminstats) | ### Database Config diff --git a/docs/releasing.md b/docs/releasing.md index 3cf390bb..79e666a7 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -109,6 +109,13 @@ Environment settings (Settings → Environments → `production`): must not be selected for the organization secret `PHALA_CLOUD_API_KEY`. A same-named repository or organization secret is still readable by a job without `environment: production`. +- `NODE_STATS_READ_TOKEN` (TC-1482, read-only `GET /admin/stats`) is not in + that list and is not passed to the CVM by `docker.yml` or the compose files, + so the route is disabled (`404`) in a deployment until a reviewed change adds + it: a `PROD_TINYCLOUD_NODE_STATS_READ_TOKEN` environment secret, the + `PHALA_ENV_` pass-through and a compose `environment` line (which changes the + reviewed compose hash). Its value must differ from + `PROD_TINYCLOUD_ADMIN_SECRET`; the node disables the route if they are equal. What this blocks: a dispatch on an unlocked branch fails the environment's branch policy, even if that branch edits `docker.yml` to name the environment. diff --git a/tinycloud-core/src/data_owners.rs b/tinycloud-core/src/data_owners.rs new file mode 100644 index 00000000..62aebfad --- /dev/null +++ b/tinycloud-core/src/data_owners.rs @@ -0,0 +1,435 @@ +//! TC-1482: the distinct wallet addresses that own data on this node, for the +//! read-only `GET /admin/stats` route. +//! +//! # What counts +//! +//! An owner counts when at least one of their spaces other than the automatic +//! `account` registry space holds data: +//! +//! - **KV**: at least one `current_kv` row with `deleted = false`; or +//! - **SQL/DuckDB**: at least one `database_artifact` row. Artifact rows are +//! never deleted (there is no delete path), so "has created a database in +//! that space" is the strongest statement the table supports; a dropped +//! database still leaves its row. +//! +//! `database_legacy_artifact` rows are fence markers for pre-identity physical +//! names, not data, and are not read. +//! +//! The owner is parsed from the space id +//! (`tinycloud:pkh:eip155::0x
:`), lowercased, and +//! de-duplicated across spaces and chains. Spaces whose owner is not an +//! `eip155` address (a `did:key` owner, for example) are skipped. +//! +//! # Cost +//! +//! One statement, no per-space work in Rust. The KV half drives from the +//! `space` table and asks, for each space, whether `current_kv` has a live row. +//! `current_kv` is keyed `(space, key)`, so that is one index probe per space +//! that stops at the first live row; it never scans a space's keys and never +//! scans the table. The artifact half is one scan of `database_artifact` +//! restricted to the `space` column, which the table's primary key +//! `(service, space, name)` covers, so no payload is read. Compare +//! `GET /admin/usage`, which issues one `store_size` call per space. + +use sea_orm::{ConnectionTrait, DbErr, Statement}; +use std::collections::BTreeSet; +use std::str::FromStr; +use tinycloud_auth::resource::SpaceId; + +/// The name of the registry space OpenKey writes at sign-in. It never counts +/// on its own. +pub const ACCOUNT_SPACE: &str = "account"; + +/// Most owners one call returns: the consumer's own limit (OpenKey refuses a +/// longer list). More is an error, never a partial list. +pub const MAX_OWNERS: usize = 200_000; + +/// Most space ids one call reads, which bounds memory (about 110 bytes each). +/// More is an error, never a partial list. Every owner has an `account` space, +/// so this is deliberately above [`MAX_OWNERS`]. +pub const MAX_SPACES_SCANNED: usize = 400_000; + +/// Why no list was produced. +#[derive(Debug)] +pub enum DataOwnersError { + /// The statement failed. + Db(DbErr), + /// More than [`MAX_SPACES_SCANNED`] spaces or [`MAX_OWNERS`] owners. + TooLarge, +} + +impl From for DataOwnersError { + fn from(error: DbErr) -> Self { + Self::Db(error) + } +} + +/// Space ids with live data: live KV in a `space` row, or any artifact. The +/// outer `LIMIT` is the memory bound plus one, so a result past it is +/// detected instead of silently cut. `UNION` removes the spaces both halves +/// return. Written for SQLite, PostgreSQL and MySQL; the only parameter is +/// the numeric bound, which is a constant of this crate. +fn query(limit: usize) -> String { + format!( + "SELECT u.id FROM (\ + SELECT s.id AS id FROM space s \ + WHERE EXISTS (SELECT 1 FROM current_kv k WHERE k.space = s.id AND NOT k.deleted) \ + UNION \ + SELECT a.space AS id FROM database_artifact a\ + ) u LIMIT {limit}" + ) +} + +/// Run the single statement and reduce its rows to owners: sorted, lowercase, +/// each exactly `0x` and 40 hex digits. +pub async fn collect(conn: &C) -> Result, DataOwnersError> { + collect_bounded(conn, MAX_SPACES_SCANNED, MAX_OWNERS).await +} + +async fn collect_bounded( + conn: &C, + max_spaces: usize, + max_owners: usize, +) -> Result, DataOwnersError> { + let rows = conn + .query_all(Statement::from_string( + conn.get_database_backend(), + query(max_spaces + 1), + )) + .await?; + if rows.len() > max_spaces { + return Err(DataOwnersError::TooLarge); + } + let mut owners = BTreeSet::new(); + for row in &rows { + let id: String = row.try_get_by_index(0)?; + if let Some(owner) = data_owner(&id) { + owners.insert(owner); + } + } + if owners.len() > max_owners { + return Err(DataOwnersError::TooLarge); + } + Ok(owners.into_iter().collect()) +} + +/// The lowercase owner address of a space id, or `None` for the `account` +/// space, an unparsable id, and an owner that is not an `eip155` address. +fn data_owner(space_id: &str) -> Option { + let space = SpaceId::from_str(space_id).ok()?; + if space.name().as_str() == ACCOUNT_SPACE { + return None; + } + // `did:pkh:eip155::
` + let mut parts = space.did().as_str().split(':'); + let (Some("did"), Some("pkh"), Some("eip155"), Some(chain), Some(address), None) = ( + parts.next(), + parts.next(), + parts.next(), + parts.next(), + parts.next(), + parts.next(), + ) else { + return None; + }; + let hex = address.strip_prefix("0x")?; + if chain.is_empty() + || !chain.bytes().all(|b| b.is_ascii_digit()) + || hex.len() != 40 + || !hex.bytes().all(|b| b.is_ascii_hexdigit()) + { + return None; + } + Some(address.to_ascii_lowercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + use sea_orm::{ConnectOptions, Database, DatabaseConnection, DbBackend}; + use sea_orm_migration::MigratorTrait; + + const ALICE: &str = "0x0abcdefabcdefabcdefabcdefabcdefabcdefabc"; + const BOB: &str = "0x1111111111111111111111111111111111111111"; + const CAROL: &str = "0x2222222222222222222222222222222222222222"; + const DAVE: &str = "0x3333333333333333333333333333333333333333"; + const EVE: &str = "0x4444444444444444444444444444444444444444"; + + fn id(chain: u64, address: &str, name: &str) -> String { + format!("tinycloud:pkh:eip155:{chain}:{address}:{name}") + } + + async fn sqlite() -> DatabaseConnection { + let conn = Database::connect(ConnectOptions::new("sqlite::memory:".to_string())) + .await + .unwrap(); + crate::migrations::Migrator::up(&conn, None).await.unwrap(); + conn + } + + async fn exec(conn: &DatabaseConnection, sql: &str, values: Vec) { + conn.execute(Statement::from_sql_and_values( + conn.get_database_backend(), + sql, + values, + )) + .await + .unwrap(); + } + + fn placeholders(conn: &DatabaseConnection, n: usize) -> String { + (1..=n) + .map(|i| match conn.get_database_backend() { + DbBackend::Postgres => format!("${i}"), + _ => "?".to_string(), + }) + .collect::>() + .join(", ") + } + + async fn add_space(conn: &DatabaseConnection, space: &str) { + let sql = format!("INSERT INTO space (id) VALUES ({})", placeholders(conn, 1)); + exec(conn, &sql, vec![space.into()]).await; + } + + async fn add_kv(conn: &DatabaseConnection, space: &str, key: &str, deleted: bool) { + let sql = format!( + "INSERT INTO current_kv (space, key, invocation, seq, epoch, epoch_seq, value, \ + metadata, deleted) VALUES ({})", + placeholders(conn, 9) + ); + let blob = |tag: &str| sea_orm::Value::Bytes(Some(Box::new(tag.as_bytes().to_vec()))); + exec( + conn, + &sql, + vec![ + space.into(), + key.into(), + blob("invocation"), + 1i64.into(), + blob("epoch"), + 1i64.into(), + blob("value"), + sea_orm::Value::Json(Some(Box::new(serde_json::json!({})))), + deleted.into(), + ], + ) + .await; + } + + async fn add_artifact(conn: &DatabaseConnection, space: &str, service: &str) { + let sql = format!( + "INSERT INTO database_artifact (service, space, name, revision, content_hash, \ + payload, size_bytes, backend, storage_mode, created_at, updated_at, \ + checkpoint_size_bytes, checkpoint_content_hash, delta_size_bytes) \ + VALUES ({})", + placeholders(conn, 14) + ); + exec( + conn, + &sql, + vec![ + service.into(), + space.into(), + "main".into(), + 1i64.into(), + "hash".into(), + sea_orm::Value::Bytes(Some(Box::new(vec![0u8; 8]))), + 8i64.into(), + "sqlite".into(), + "checkpoint".into(), + "now".into(), + "now".into(), + 8i64.into(), + "hash".into(), + 0i64.into(), + ], + ) + .await; + } + + async fn owners(conn: &DatabaseConnection) -> Vec { + collect(conn).await.unwrap() + } + + /// The behaviour contract, run against whichever backend `conn` is. + async fn assert_owner_rules(conn: &DatabaseConnection) { + assert!(owners(conn).await.is_empty(), "an empty node has no owners"); + + // Only the automatic registry space: not counted, whatever it holds. + let alice_account = id(1, ALICE, "account"); + add_space(conn, &alice_account).await; + add_kv(conn, &alice_account, "bootstrap/complete", false).await; + add_artifact(conn, &alice_account, "sql").await; + assert!(owners(conn).await.is_empty()); + + // A space with no data at all is not counted either. + let empty = id(1, BOB, "default"); + add_space(conn, &empty).await; + assert!(owners(conn).await.is_empty()); + + // Registry + one data space: counted once. + let alice_data = id(1, ALICE, "default"); + add_space(conn, &alice_data).await; + add_kv(conn, &alice_data, "notes/a", false).await; + add_kv(conn, &alice_data, "notes/b", false).await; + assert_eq!(owners(conn).await, vec![ALICE]); + + // Same owner on another chain and another space: still once. + let alice_chain = id(137, ALICE, "photos"); + add_space(conn, &alice_chain).await; + add_kv(conn, &alice_chain, "p/1", false).await; + let alice_more = id(1, ALICE, "work"); + add_space(conn, &alice_more).await; + add_kv(conn, &alice_more, "w/1", false).await; + assert_eq!(owners(conn).await, vec![ALICE]); + + // Mixed-case spelling of the same address dedupes with the lowercase one. + let alice_upper = id(1, "0x0ABCDEFabcdefABCDEFabcdefABCDEFabcdefABC", "mixed"); + add_space(conn, &alice_upper).await; + add_kv(conn, &alice_upper, "m/1", false).await; + assert_eq!(owners(conn).await, vec![ALICE]); + + // Deleted-only data: not counted. A live row beside tombstones: counted. + let carol = id(1, CAROL, "default"); + add_space(conn, &carol).await; + add_kv(conn, &carol, "gone/1", true).await; + add_kv(conn, &carol, "gone/2", true).await; + assert_eq!(owners(conn).await, vec![ALICE]); + let dave = id(1, DAVE, "default"); + add_space(conn, &dave).await; + add_kv(conn, &dave, "a", true).await; + add_kv(conn, &dave, "b", false).await; + assert_eq!(owners(conn).await, vec![ALICE, DAVE]); + + // Artifact-only owner (SQL or DuckDB, no KV, no `space` row needed). + let eve = id(1, EVE, "analytics"); + add_artifact(conn, &eve, "sql").await; + add_artifact(conn, &eve, "duckdb").await; + assert_eq!(owners(conn).await, vec![ALICE, DAVE, EVE]); + + // Spaces holding live data whose owner is not a 40-digit hex eip155 + // address never reach the list. + for malformed in [ + id(1, "0xabc", "default"), + id(1, "0x00000000000000000000000000000000000000001", "default"), + id(1, "0xgggggggggggggggggggggggggggggggggggggggg", "default"), + id(1, "0000000000000000000000000000000000000001", "default"), + "tinycloud:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK:default".to_owned(), + "tinycloud:pkh:solana:1:0x0000000000000000000000000000000000000001:default".to_owned(), + "not-a-space-id".to_owned(), + ] { + add_space(conn, &malformed).await; + add_kv(conn, &malformed, "k", false).await; + } + add_artifact(conn, "also-not-a-space-id", "sql").await; + assert_eq!(owners(conn).await, vec![ALICE, DAVE, EVE]); + + // Every entry is exactly `0x` + 40 lowercase hex digits, sorted. + let result = owners(conn).await; + assert!(result.iter().all(|owner| { + owner.len() == 42 + && owner.starts_with("0x") + && owner[2..] + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + })); + let mut sorted = result.clone(); + sorted.sort(); + assert_eq!(result, sorted); + } + + #[tokio::test] + async fn sqlite_owner_rules() { + assert_owner_rules(&sqlite().await).await; + } + + #[tokio::test] + async fn postgres_owner_rules() { + let Some(schema) = + crate::test_support::PostgresTestSchema::new("postgres_owner_rules").await + else { + return; + }; + assert_owner_rules(&schema.conn).await; + } + + /// Past either bound the result is an error, never a shorter list. + async fn assert_bounds_fail_instead_of_truncating(conn: &DatabaseConnection) { + for n in 0..5u64 { + let space = id(1, &format!("0x{n:040x}"), "default"); + add_space(conn, &space).await; + add_kv(conn, &space, "k", false).await; + } + // Exactly at both bounds is fine. + assert_eq!(collect_bounded(conn, 5, 5).await.unwrap().len(), 5); + assert_eq!(collect_bounded(conn, 50, 50).await.unwrap().len(), 5); + // One more space than may be read. + assert!(matches!( + collect_bounded(conn, 4, 50).await, + Err(DataOwnersError::TooLarge) + )); + // One more owner than may be returned. + assert!(matches!( + collect_bounded(conn, 50, 4).await, + Err(DataOwnersError::TooLarge) + )); + } + + #[tokio::test] + async fn sqlite_bounds_fail_instead_of_truncating() { + assert_bounds_fail_instead_of_truncating(&sqlite().await).await; + } + + #[tokio::test] + async fn postgres_bounds_fail_instead_of_truncating() { + let Some(schema) = crate::test_support::PostgresTestSchema::new( + "postgres_bounds_fail_instead_of_truncating", + ) + .await + else { + return; + }; + assert_bounds_fail_instead_of_truncating(&schema.conn).await; + } + + #[test] + fn owner_parsing() { + let lower = "0x0abcdefabcdefabcdefabcdefabcdefabcdefabc"; + assert_eq!( + data_owner(&format!("tinycloud:pkh:eip155:1:{lower}:default")), + Some(lower.to_string()) + ); + assert_eq!( + data_owner("tinycloud:pkh:eip155:10:0x0ABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABC:x"), + Some(lower.to_string()) + ); + // The registry space, exactly named, is the only exclusion; a longer or + // differently cased name is data. + assert_eq!( + data_owner(&format!("tinycloud:pkh:eip155:1:{lower}:account")), + None + ); + assert!(data_owner(&format!("tinycloud:pkh:eip155:1:{lower}:account2")).is_some()); + assert!(data_owner(&format!("tinycloud:pkh:eip155:1:{lower}:Account")).is_some()); + // Not a 40-digit eip155 address owner. + assert_eq!( + data_owner("tinycloud:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK:default"), + None + ); + assert_eq!(data_owner("tinycloud:pkh:eip155:1:0xabc:default"), None); + assert_eq!( + data_owner("tinycloud:pkh:eip155:1:0x0abcdefabcdefabcdefabcdefabcdefabcdefabc0:d"), + None + ); + assert_eq!( + data_owner("tinycloud:pkh:eip155:1:0xzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz:d"), + None + ); + assert_eq!( + data_owner("tinycloud:pkh:eip155::0x0abcdefabcdefabcdefabcdefabcdefabcdefabc:d"), + None + ); + assert_eq!(data_owner("garbage"), None); + } +} diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index ab0ac2c4..29455b0b 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -721,6 +721,13 @@ where .collect()) } + /// The distinct owner addresses that hold data beyond the automatic + /// `account` space (TC-1482), from a single aggregate statement. See + /// [`crate::data_owners`] for exactly what is counted. + pub async fn data_owners(&self) -> Result, crate::data_owners::DataOwnersError> { + crate::data_owners::collect(&self.conn).await + } + /// Return lifecycle-complete delegations related to the authenticated account. /// /// The account is derived from the verified invocation signer and its one diff --git a/tinycloud-core/src/lib.rs b/tinycloud-core/src/lib.rs index beedf46c..874e96dc 100644 --- a/tinycloud-core/src/lib.rs +++ b/tinycloud-core/src/lib.rs @@ -1,6 +1,7 @@ pub mod admission; pub mod at_rest; pub(crate) mod auth_graph; +pub mod data_owners; pub mod database_artifacts; pub mod database_identity; pub mod database_migration; diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index 009ee7e7..05758fd9 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -121,6 +121,7 @@ use routes::tc_bench::{ }; use routes::{ admin::{delete_quota, get_quota, get_usage, list_quotas, set_quota}, + admin_stats::get_stats, attestation::attestation, create_signed_kv_url, delegate, delegation_query, delegation_status, encryption::{ @@ -477,6 +478,7 @@ fn production_routes() -> Vec { get_quota, list_quotas, get_usage, + get_stats, create_encryption_network, get_encryption_network, encryption_well_known, diff --git a/tinycloud-node-server/src/routes/admin_stats.rs b/tinycloud-node-server/src/routes/admin_stats.rs new file mode 100644 index 00000000..b679528e --- /dev/null +++ b/tinycloud-node-server/src/routes/admin_stats.rs @@ -0,0 +1,152 @@ +//! TC-1482: `GET /admin/stats`, a read-only list of the wallet addresses that +//! hold data on this node, for the scoreboard's "protocol users" figure. +//! +//! # Authentication +//! +//! A dedicated bearer token, `NODE_STATS_READ_TOKEN`, separate from +//! `TINYCLOUD_ADMIN_SECRET`. The two never open each other's routes: +//! +//! - the admin secret is refused here, and this token is refused by every +//! `AdminAuth` route (`/admin/quota*`, `/admin/usage`), because each guard +//! compares only against its own secret; +//! - if both are set to the same value, this route is disabled, so one value +//! can never open both surfaces. +//! +//! When the token is unset, empty or whitespace-only the route is disabled and +//! answers exactly as an unknown route does (a 404 from the default catcher), +//! whatever the request carries. A missing, malformed or wrong credential is a +//! bare 401 with no detail. The comparison hashes both sides to fixed-length +//! SHA-256 digests and compares those in constant time, so neither the content +//! nor the length of the token is observable through timing. +//! +//! # Logging +//! +//! Neither the token nor an address is ever written anywhere. The guard's +//! error strings are constants, a failed query logs a constant message without +//! the database error, and the handler never records a space id with request +//! telemetry. The request logger records the route template only. The response +//! carries `Cache-Control: no-store`. +//! +//! # Failure +//! +//! The consumer (OpenKey) makes no partial use of a response, so the route +//! never returns a partial list: a result past the bounds in +//! `tinycloud_core::data_owners` (200,000 owners, 400,000 spaces read) is a +//! bare `507`, and a database failure a bare `500`. + +use rocket::{ + http::{Header, Status}, + request::{FromRequest, Outcome, Request}, + response::{self, Responder, Response}, + serde::json::Json, + State, +}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; + +use tinycloud_core::data_owners::DataOwnersError; + +use crate::TinyCloud; + +/// The environment variable that carries the read-only stats token. +pub const STATS_TOKEN_ENV: &str = "NODE_STATS_READ_TOKEN"; + +/// Constant-time equality of two secrets of any length. +fn secrets_match(a: &str, b: &str) -> bool { + Sha256::digest(a.as_bytes()) + .ct_eq(&Sha256::digest(b.as_bytes())) + .into() +} + +/// The configured stats token, or `None` when the route is disabled: unset, +/// empty or whitespace-only, not valid UTF-8, or equal to the admin secret. +fn configured_token() -> Option { + let token = std::env::var(STATS_TOKEN_ENV).ok()?; + if token.trim().is_empty() { + return None; + } + if let Ok(admin) = std::env::var("TINYCLOUD_ADMIN_SECRET") { + if secrets_match(&token, &admin) { + return None; + } + } + Some(token) +} + +/// Request guard: `Authorization: Bearer `. +pub struct StatsAuth; + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for StatsAuth { + type Error = &'static str; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + // Disabled: indistinguishable from a route that does not exist. + let Some(token) = configured_token() else { + return Outcome::Forward(Status::NotFound); + }; + match request + .headers() + .get_one("Authorization") + .and_then(|header| header.strip_prefix("Bearer ")) + { + Some(presented) if secrets_match(presented, &token) => Outcome::Success(StatsAuth), + _ => Outcome::Error((Status::Unauthorized, "unauthorized")), + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StatsResponse { + /// Lowercase `0x` + 40 hex digit addresses, sorted ascending. Always the + /// complete list: a result past the bounds is an error, never a partial + /// `200`. + owners: Vec, + count: usize, + generated_at: String, +} + +/// A JSON body that no cache may store. +pub struct NoStore(R); + +impl<'r, 'o: 'r, R: Responder<'r, 'o>> Responder<'r, 'o> for NoStore { + fn respond_to(self, request: &'r Request<'_>) -> response::Result<'o> { + Response::build_from(self.0.respond_to(request)?) + .header(Header::new("Cache-Control", "no-store")) + .ok() + } +} + +/// The distinct owner addresses that hold data beyond the automatic `account` +/// space. One aggregate statement; see `tinycloud_core::data_owners`. +#[get("/admin/stats")] +pub async fn get_stats( + _auth: StatsAuth, + tinycloud: &State, +) -> Result>, Status> { + let owners = tinycloud.data_owners().await.map_err(|error| { + // Constant messages only: the error text is not needed here and + // nothing derived from the data may reach the logs (TC-961). + match error { + DataOwnersError::TooLarge => { + ::tracing::error!("admin stats result exceeds its bound"); + Status::InsufficientStorage + } + DataOwnersError::Db(_) => { + ::tracing::error!("admin stats query failed"); + Status::InternalServerError + } + } + })?; + let generated_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|_| Status::InternalServerError)?; + Ok(NoStore(Json(StatsResponse { + count: owners.len(), + owners, + generated_at, + }))) +} diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index 96ee9e35..e8873b8c 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -67,6 +67,7 @@ use tinycloud_core::{ }; pub mod admin; +pub mod admin_stats; pub mod attestation; pub mod encryption; pub mod hooks; diff --git a/tinycloud-node-server/tests/admin_stats.rs b/tinycloud-node-server/tests/admin_stats.rs new file mode 100644 index 00000000..3822fedf --- /dev/null +++ b/tinycloud-node-server/tests/admin_stats.rs @@ -0,0 +1,633 @@ +//! TC-1482: `GET /admin/stats` over the real node, on SQLite and (when +//! `TINYCLOUD_TEST_POSTGRES_URL` is set, mandatory in CI) PostgreSQL. +//! +//! The response rules are pinned in `tinycloud-core`'s `data_owners` tests; the +//! tests here cover the HTTP surface: the authentication matrix, the disabled +//! route, the response shape and one end-to-end data scenario per backend. +//! `tests/admin_stats_logs.rs` covers the logs and +//! `tests/postgres_round_trip_budget.rs` the round trips. + +use std::ffi::OsString; + +use rocket::{ + figment::providers::{Format, Serialized, Toml}, + http::{Header, Status}, + local::asynchronous::Client, +}; +use serde_json::{json, Value}; +use tinycloud_core::sea_orm::{ + ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DbBackend, Statement, +}; + +const STATS_TOKEN: &str = "tc1482-stats-token-3f9a1c7e5b2d4a60"; +const ADMIN_SECRET: &str = "tc1482-admin-secret-9d8c7b6a5f4e3d2c"; +const TOKEN_ENV: &str = "NODE_STATS_READ_TOKEN"; +const ADMIN_ENV: &str = "TINYCLOUD_ADMIN_SECRET"; + +const ALICE: &str = "0x0abcdefabcdefabcdefabcdefabcdefabcdefabc"; +const ALICE_MIXED: &str = "0x0ABCDEFabcdefABCDEFabcdefABCDEFabcdefABC"; +const BOB: &str = "0x1111111111111111111111111111111111111111"; +const CAROL: &str = "0x2222222222222222222222222222222222222222"; +const DAVE: &str = "0x3333333333333333333333333333333333333333"; + +/// The two secrets live in the process environment, which the guards read on +/// every request, so tests that set them run one at a time. +static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +/// Sets (or clears) the two secrets and restores the previous values on drop. +struct Env(Vec<(&'static str, Option)>); + +impl Env { + fn set(stats: Option<&str>, admin: Option<&str>) -> Self { + let saved = [TOKEN_ENV, ADMIN_ENV] + .map(|name| (name, std::env::var_os(name))) + .to_vec(); + Self::apply(TOKEN_ENV, stats); + Self::apply(ADMIN_ENV, admin); + Self(saved) + } + + fn apply(name: &str, value: Option<&str>) { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + + fn stats(&self, value: Option<&str>) { + Self::apply(TOKEN_ENV, value); + } +} + +impl Drop for Env { + fn drop(&mut self) { + for (name, value) in &self.0 { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } +} + +/// A throwaway PostgreSQL schema, dropped with the fixture. +struct PgSchema { + url: String, + schema: String, +} + +impl Drop for PgSchema { + fn drop(&mut self) { + let (url, schema) = (self.url.clone(), self.schema.clone()); + let _ = std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async move { + let admin = Database::connect(url).await?; + admin + .execute(Statement::from_string( + DbBackend::Postgres, + format!("DROP SCHEMA IF EXISTS {schema} CASCADE"), + )) + .await + .map(|_| ()) + }) + }) + .join(); + } +} + +struct Node { + client: Client, + seed: DatabaseConnection, + _dir: tempfile::TempDir, + _schema: Option, +} + +#[derive(Clone, Copy, PartialEq)] +enum Backend { + Sqlite, + Postgres, +} + +struct Reply { + status: Status, + body: String, + content_type: Option, + cache_control: Option, +} + +impl Reply { + fn json(&self) -> Value { + serde_json::from_str(&self.body).unwrap_or_else(|_| panic!("not JSON: {}", self.body)) + } +} + +fn postgres_test_url(test: &str) -> Option { + match std::env::var("TINYCLOUD_TEST_POSTGRES_URL") { + Ok(url) if !url.trim().is_empty() => Some(url), + _ => { + if std::env::var_os("CI").is_some() { + panic!( + "{test} requires TINYCLOUD_TEST_POSTGRES_URL, which is unset (or empty) in CI \ + (TC-381)." + ); + } + eprintln!("skipping {test}: TINYCLOUD_TEST_POSTGRES_URL is unset"); + None + } + } +} + +impl Node { + /// `None` only when PostgreSQL is requested, not configured, and not CI. + async fn start(backend: Backend, test: &str) -> Option { + let dir = tempfile::tempdir().unwrap(); + let (database, schema) = match backend { + Backend::Sqlite => ( + format!("sqlite:{}?mode=rwc", dir.path().join("node.db").display()), + None, + ), + Backend::Postgres => { + let url = postgres_test_url(test)?; + let schema = format!( + "tc1482_{}_{}", + std::process::id(), + time::OffsetDateTime::now_utc().unix_timestamp_nanos() + ); + let admin = Database::connect(url.as_str()).await.unwrap(); + admin + .execute(Statement::from_string( + DbBackend::Postgres, + format!("CREATE SCHEMA {schema}"), + )) + .await + .unwrap(); + let mut scoped = reqwest::Url::parse(&url).unwrap(); + scoped + .query_pairs_mut() + .append_pair("options", &format!("-c search_path={schema}")); + (scoped.to_string(), Some(PgSchema { url, schema })) + } + }; + let overlay = format!( + r#" +log_level = "critical" +[storage] +datadir = "{}" +database = "{}" +[keys] +type = "Static" +secret = "{}" +[database] +max_connections = 4 +[epoch_head_backfill] +enabled = false +"#, + dir.path().join("data").display(), + database, + base64::encode_config([9u8; 32], base64::URL_SAFE_NO_PAD), + ); + let figment = rocket::Config::figment() + .merge(Serialized::defaults(tinycloud::config::Config::default())) + .merge(Toml::string(&overlay)); + let config = figment.extract::().unwrap(); + let rocket = tinycloud::app_with_control(&figment, &config, None) + .await + .unwrap(); + let seed = Database::connect(ConnectOptions::new(database)) + .await + .unwrap(); + Some(Node { + client: Client::tracked(rocket).await.unwrap(), + seed, + _dir: dir, + _schema: schema, + }) + } + + fn placeholders(&self, n: usize) -> String { + (1..=n) + .map(|i| match self.seed.get_database_backend() { + DbBackend::Postgres => format!("${i}"), + _ => "?".to_owned(), + }) + .collect::>() + .join(", ") + } + + async fn exec(&self, sql: String, values: Vec) { + self.seed + .execute(Statement::from_sql_and_values( + self.seed.get_database_backend(), + sql, + values, + )) + .await + .unwrap(); + } + + async fn add_space(&self, space: &str) { + self.exec( + format!("INSERT INTO space (id) VALUES ({})", self.placeholders(1)), + vec![space.into()], + ) + .await; + } + + async fn add_kv(&self, space: &str, key: &str, deleted: bool) { + use tinycloud_core::sea_orm::Value; + let blob = |tag: &str| Value::Bytes(Some(Box::new(tag.as_bytes().to_vec()))); + self.exec( + format!( + "INSERT INTO current_kv (space, key, invocation, seq, epoch, epoch_seq, value, \ + metadata, deleted) VALUES ({})", + self.placeholders(9) + ), + vec![ + space.into(), + key.into(), + blob("invocation"), + 1i64.into(), + blob("epoch"), + 1i64.into(), + blob("value"), + Value::Json(Some(Box::new(json!({})))), + deleted.into(), + ], + ) + .await; + } + + async fn add_artifact(&self, space: &str) { + use tinycloud_core::sea_orm::Value; + self.exec( + format!( + "INSERT INTO database_artifact (service, space, name, revision, content_hash, \ + payload, size_bytes, backend, storage_mode, created_at, updated_at, \ + checkpoint_size_bytes, checkpoint_content_hash, delta_size_bytes) VALUES ({})", + self.placeholders(14) + ), + vec![ + "sql".into(), + space.into(), + "main".into(), + 1i64.into(), + "hash".into(), + Value::Bytes(Some(Box::new(vec![0u8; 8]))), + 8i64.into(), + "sqlite".into(), + "checkpoint".into(), + "now".into(), + "now".into(), + 8i64.into(), + "hash".into(), + 0i64.into(), + ], + ) + .await; + } + + async fn request( + &self, + method: rocket::http::Method, + path: &str, + authorization: Option<&str>, + body: Option<&str>, + ) -> Reply { + let mut request = self.client.req(method, path); + if let Some(value) = authorization { + request = request.header(Header::new("Authorization", value.to_owned())); + } + if let Some(body) = body { + request = request.header(rocket::http::ContentType::JSON).body(body); + } + let response = request.dispatch().await; + let status = response.status(); + let content_type = response + .headers() + .get_one("Content-Type") + .map(str::to_owned); + let cache_control = response + .headers() + .get_one("Cache-Control") + .map(str::to_owned); + Reply { + status, + body: response.into_string().await.unwrap_or_default(), + content_type, + cache_control, + } + } + + async fn get(&self, path: &str, authorization: Option<&str>) -> Reply { + self.request(rocket::http::Method::Get, path, authorization, None) + .await + } + + async fn stats(&self, authorization: Option<&str>) -> Reply { + self.get("/admin/stats", authorization).await + } +} + +fn bearer(token: &str) -> String { + format!("Bearer {token}") +} + +fn space(chain: u64, owner: &str, name: &str) -> String { + format!("tinycloud:pkh:eip155:{chain}:{owner}:{name}") +} + +/// One end-to-end data scenario: every rule the scoreboard relies on, through +/// the route. +async fn assert_stats_scenario(backend: Backend, test: &str) { + let _lock = ENV_LOCK.lock().await; + let _env = Env::set(Some(STATS_TOKEN), Some(ADMIN_SECRET)); + let Some(node) = Node::start(backend, test).await else { + return; + }; + let auth = bearer(STATS_TOKEN); + + let empty = node.stats(Some(&auth)).await; + assert_eq!(empty.status, Status::Ok); + assert_eq!(empty.json()["owners"], json!([])); + assert_eq!(empty.json()["count"], 0); + + // Alice: the automatic registry space only (KV and a database in it). + let alice_account = space(1, ALICE, "account"); + node.add_space(&alice_account).await; + node.add_kv(&alice_account, "bootstrap/complete", false) + .await; + node.add_artifact(&alice_account).await; + // Bob: a data space whose only rows are deleted. + let bob = space(1, BOB, "default"); + node.add_space(&bob).await; + node.add_kv(&bob, "gone", true).await; + assert_eq!(node.stats(Some(&auth)).await.json()["owners"], json!([])); + + // Alice stores data on two chains, once under a mixed-case spelling. + for (chain, owner, name) in [ + (1, ALICE, "default"), + (137, ALICE, "photos"), + (1, ALICE_MIXED, "mixed"), + ] { + let id = space(chain, owner, name); + node.add_space(&id).await; + node.add_kv(&id, "k", false).await; + } + // Carol has only a SQL/DuckDB database; Dave has a live key beside a tombstone. + node.add_artifact(&space(1, CAROL, "analytics")).await; + let dave = space(1, DAVE, "default"); + node.add_space(&dave).await; + node.add_kv(&dave, "old", true).await; + node.add_kv(&dave, "new", false).await; + + let reply = node.stats(Some(&auth)).await; + assert_eq!(reply.status, Status::Ok); + assert_eq!(reply.content_type.as_deref(), Some("application/json")); + assert_eq!(reply.cache_control.as_deref(), Some("no-store")); + let body = reply.json(); + assert_eq!(body["owners"], json!([ALICE, CAROL, DAVE])); + assert_eq!(body["count"], 3); + let mut keys: Vec<&String> = body.as_object().unwrap().keys().collect(); + keys.sort(); + assert_eq!(keys, ["count", "generatedAt", "owners"]); + time::OffsetDateTime::parse( + body["generatedAt"].as_str().unwrap(), + &time::format_description::well_known::Rfc3339, + ) + .expect("generatedAt is RFC 3339"); + assert!( + !reply.body.contains("tinycloud:pkh"), + "no space id in the body" + ); + assert!( + !reply.body.contains(&ALICE_MIXED[2..]), + "addresses are lowercased" + ); +} + +#[tokio::test] +async fn sqlite_stats_scenario() { + assert_stats_scenario(Backend::Sqlite, "sqlite_stats_scenario").await; +} + +#[tokio::test] +async fn postgres_stats_scenario() { + assert_stats_scenario(Backend::Postgres, "postgres_stats_scenario").await; +} + +#[tokio::test] +async fn auth_matrix() { + let _lock = ENV_LOCK.lock().await; + let env = Env::set(Some(STATS_TOKEN), Some(ADMIN_SECRET)); + let node = Node::start(Backend::Sqlite, "auth_matrix").await.unwrap(); + let nothing = || None::<&str>; + + // Credentials that must all be refused with the same bare 401. + let wrong = [ + nothing().map(str::to_owned), + Some(String::new()), + Some("Bearer ".to_owned()), + Some(bearer("")), + Some(bearer("wrong-token")), + Some(bearer(&format!("{STATS_TOKEN}x"))), + Some(bearer(&STATS_TOKEN[..STATS_TOKEN.len() - 1])), + Some(bearer(&STATS_TOKEN.to_uppercase())), + Some(bearer(ADMIN_SECRET)), + Some(format!("Basic {STATS_TOKEN}")), + Some(format!("bearer {STATS_TOKEN}")), + Some(STATS_TOKEN.to_owned()), + ]; + let mut bodies = Vec::new(); + for authorization in &wrong { + let reply = node.stats(authorization.as_deref()).await; + assert_eq!( + reply.status, + Status::Unauthorized, + "{authorization:?} must be refused" + ); + assert!(!reply.body.contains(STATS_TOKEN)); + assert!(!reply.body.contains(ADMIN_SECRET)); + bodies.push((reply.body, reply.content_type, reply.cache_control)); + } + assert!( + bodies.windows(2).all(|pair| pair[0] == pair[1]), + "every refusal looks the same: {bodies:?}" + ); + assert!(!bodies[0].0.contains("alid"), "no detail in the refusal"); + + let ok = node.stats(Some(&bearer(STATS_TOKEN))).await; + assert_eq!(ok.status, Status::Ok); + assert_eq!(ok.json()["owners"], json!([])); + + // Read-only: no other method is routed. + for method in [ + rocket::http::Method::Post, + rocket::http::Method::Put, + rocket::http::Method::Patch, + rocket::http::Method::Delete, + ] { + let reply = node + .request( + method, + "/admin/stats", + Some(&bearer(STATS_TOKEN)), + Some("{}"), + ) + .await; + assert_eq!(reply.status, Status::NotFound, "{method}"); + } + + // The stats token opens no admin route, with the admin secret configured... + let victim = space(1, ALICE, "default"); + let quota = format!("/admin/quota/{victim}"); + let stats_auth = bearer(STATS_TOKEN); + for (method, path, body) in [ + ( + rocket::http::Method::Put, + quota.as_str(), + Some(r#"{"limit_bytes":1}"#), + ), + (rocket::http::Method::Delete, quota.as_str(), None), + (rocket::http::Method::Get, quota.as_str(), None), + (rocket::http::Method::Get, "/admin/quota", None), + (rocket::http::Method::Get, "/admin/usage", None), + ] { + let reply = node.request(method, path, Some(&stats_auth), body).await; + assert_eq!( + reply.status, + Status::Unauthorized, + "{method} {path} accepted the stats token" + ); + } + // ...and with it unset. + env.stats(Some(STATS_TOKEN)); + std::env::remove_var(ADMIN_ENV); + for (method, path, body) in [ + ( + rocket::http::Method::Put, + quota.as_str(), + Some(r#"{"limit_bytes":1}"#), + ), + (rocket::http::Method::Delete, quota.as_str(), None), + (rocket::http::Method::Get, "/admin/usage", None), + ] { + let reply = node.request(method, path, Some(&stats_auth), body).await; + assert!( + reply.status.class().is_client_error() || reply.status.class().is_server_error(), + "{method} {path} accepted the stats token with no admin secret" + ); + } + // The admin secret still opens the admin routes it always did. + std::env::set_var(ADMIN_ENV, ADMIN_SECRET); + let usage = node.get("/admin/usage", Some(&bearer(ADMIN_SECRET))).await; + assert_eq!(usage.status, Status::Ok); +} + +#[tokio::test] +async fn disabled_route_is_an_unknown_route() { + let _lock = ENV_LOCK.lock().await; + let env = Env::set(None, Some(ADMIN_SECRET)); + let node = Node::start(Backend::Sqlite, "disabled_route") + .await + .unwrap(); + + let unknown = node.get("/admin/stats-does-not-exist", None).await; + assert_eq!(unknown.status, Status::NotFound); + let shape = |reply: &Reply| { + ( + reply.status, + reply.body.clone(), + reply.content_type.clone(), + reply.cache_control.clone(), + ) + }; + + // Unset, empty, whitespace-only and equal-to-the-admin-secret all disable + // the route, whatever credential the request carries. + let disabled: [Option<&str>; 4] = [None, Some(""), Some(" \t\n"), Some(ADMIN_SECRET)]; + for value in disabled { + env.stats(value); + for authorization in [ + None, + Some(bearer(STATS_TOKEN)), + Some(bearer(ADMIN_SECRET)), + Some(bearer("")), + ] { + let reply = node.stats(authorization.as_deref()).await; + assert_eq!( + shape(&reply), + shape(&unknown), + "NODE_STATS_READ_TOKEN={value:?} with {authorization:?} must look like an unknown route" + ); + } + } + + // Enabling it takes effect for the next request; the admin secret is + // still refused and the token accepted. + env.stats(Some(STATS_TOKEN)); + assert_eq!( + node.stats(Some(&bearer(ADMIN_SECRET))).await.status, + Status::Unauthorized + ); + assert_eq!( + node.stats(Some(&bearer(STATS_TOKEN))).await.status, + Status::Ok + ); +} + +/// The consumer makes no partial use of a response, so past the 200,000-owner +/// bound the route fails; it never answers a shorter list with a `200`. +#[tokio::test] +async fn more_than_the_owner_bound_is_an_error_not_a_partial_list() { + const BOUND: u64 = 200_000; + let _lock = ENV_LOCK.lock().await; + let _env = Env::set(Some(STATS_TOKEN), Some(ADMIN_SECRET)); + let node = Node::start(Backend::Sqlite, "owner_bound").await.unwrap(); + let auth = bearer(STATS_TOKEN); + + let owner = "'tinycloud:pkh:eip155:1:0x' || printf('%040x', x) || ':default'"; + let series = format!( + "WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < {BOUND})" + ); + node.exec( + format!("{series} INSERT INTO space (id) SELECT {owner} FROM n"), + vec![], + ) + .await; + node.exec( + format!( + "{series} INSERT INTO current_kv (space, key, invocation, seq, epoch, epoch_seq, \ + value, metadata, deleted) SELECT {owner}, 'k', x'01', 1, x'02', 1, x'03', '{{}}', 0 \ + FROM n" + ), + vec![], + ) + .await; + + let at_bound = node.stats(Some(&auth)).await; + assert_eq!(at_bound.status, Status::Ok); + let body = at_bound.json(); + assert_eq!(body["count"], BOUND); + let owners = body["owners"].as_array().unwrap(); + assert_eq!(owners.len() as u64, BOUND); + assert!(owners.iter().all(|owner| { + let owner = owner.as_str().unwrap(); + owner.len() == 42 + && owner.starts_with("0x") + && owner[2..] + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + })); + + let extra = space(1, BOB, "default"); + node.add_space(&extra).await; + node.add_kv(&extra, "k", false).await; + let over = node.stats(Some(&auth)).await; + assert_eq!(over.status, Status::InsufficientStorage); + assert!(!over.body.contains("0x"), "no addresses in the failure"); + assert!(!over.body.contains("owners")); +} diff --git a/tinycloud-node-server/tests/admin_stats_logs.rs b/tinycloud-node-server/tests/admin_stats_logs.rs new file mode 100644 index 00000000..4fdb326a --- /dev/null +++ b/tinycloud-node-server/tests/admin_stats_logs.rs @@ -0,0 +1,235 @@ +//! TC-1482 / TC-961: production logs are publicly readable, so neither the +//! stats token, the admin secret, nor a wallet address returned by +//! `GET /admin/stats` may reach them. +//! +//! Runs the real `tinycloud` binary (as `tests/request_log_redaction.rs` does) +//! over a SQLite file seeded with a space whose owner carries a unique +//! address, drives the route with the right token, the wrong token, the admin +//! secret and no credential, and searches everything the process wrote to +//! stdout and stderr. + +use std::{ + fs::File, + net::TcpListener, + path::Path, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; + +use tinycloud_core::sea_orm::{ConnectionTrait, Database, DbBackend, Statement, Value}; + +const STATIC_SECRET: &str = "VEMtMTQ4MiByZXF1ZXN0IGxvZyBsZWFrIHRlc3Qgc2VjcmV0IGtleSBtYXRlcmlhbA"; + +struct KillOnDrop(Child); + +impl Drop for KillOnDrop { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn free_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +fn marker(prefix: &str) -> String { + format!("{prefix}{}", uuid::Uuid::new_v4().simple()) +} + +/// Drop ANSI escape sequences so a colored field name can't split a match. +fn strip_ansi(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars(); + while let Some(c) = chars.next() { + if c == '\u{1b}' { + for c in chars.by_ref() { + if c.is_ascii_alphabetic() { + break; + } + } + } else { + out.push(c); + } + } + out +} + +fn read_logs(dir: &Path) -> String { + let stdout = std::fs::read_to_string(dir.join("stdout.log")).unwrap(); + let stderr = std::fs::read_to_string(dir.join("stderr.log")).unwrap(); + strip_ansi(&format!("{stdout}{stderr}")) +} + +fn wait_until_healthy(client: &reqwest::blocking::Client, base: &str, server: &mut Child) { + let deadline = Instant::now() + Duration::from_secs(60); + while Instant::now() < deadline { + if let Some(status) = server.try_wait().unwrap() { + panic!("tinycloud exited before becoming healthy: {status}"); + } + if let Ok(response) = client.get(format!("{base}/healthz")).send() { + if response.status().is_success() { + return; + } + } + std::thread::sleep(Duration::from_millis(100)); + } + panic!("tinycloud did not become healthy within 60s"); +} + +/// One space with a live key, owned by `owner`. +async fn seed(database: &str, space: &str) { + let conn = Database::connect(database).await.unwrap(); + let blob = |tag: &str| Value::Bytes(Some(Box::new(tag.as_bytes().to_vec()))); + for (sql, values) in [ + ("INSERT INTO space (id) VALUES (?)", vec![space.into()]), + ( + "INSERT INTO current_kv (space, key, invocation, seq, epoch, epoch_seq, value, \ + metadata, deleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + vec![ + space.into(), + "k".into(), + blob("invocation"), + 1i64.into(), + blob("epoch"), + 1i64.into(), + blob("value"), + Value::Json(Some(Box::new(serde_json::json!({})))), + false.into(), + ], + ), + ] { + conn.execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + sql, + values, + )) + .await + .unwrap(); + } + conn.close().await.unwrap(); +} + +#[test] +fn production_logs_carry_no_stats_token_or_address() { + assert_stats_data_stays_out_of_logs(&[]); +} + +#[test] +fn debug_logs_carry_no_stats_token_or_address() { + assert_stats_data_stays_out_of_logs(&[("TINYCLOUD_LOG_LEVEL", "debug")]); +} + +#[test] +fn trace_logs_carry_no_stats_token_or_address() { + assert_stats_data_stays_out_of_logs(&[("RUST_LOG", "trace")]); +} + +fn assert_stats_data_stays_out_of_logs(extra_env: &[(&str, &str)]) { + let dir = tempfile::tempdir().unwrap(); + let port = free_port(); + let base = format!("http://127.0.0.1:{port}"); + let database = format!("sqlite:{}?mode=rwc", dir.path().join("node.db").display()); + + let token = marker("tc1482token"); + let admin_secret = marker("tc1482admin"); + let wrong_token = marker("tc1482wrong"); + let wallet = uuid::Uuid::new_v4().simple().to_string() + "0000000a"; + let space = format!("tinycloud:pkh:eip155:1:0x{wallet}:default"); + + let mut command = Command::new(env!("CARGO_BIN_EXE_tinycloud")); + // No inherited node configuration: only the settings below apply. + for (name, _) in std::env::vars_os() { + let name_str = name.to_string_lossy(); + if name_str.starts_with("TINYCLOUD_") + || name_str.starts_with("ROCKET_") + || name_str == "RUST_LOG" + || name_str == "NODE_STATS_READ_TOKEN" + { + command.env_remove(&name); + } + } + let server = command + .current_dir(dir.path()) + .env("TINYCLOUD_CONFIG_FILE", dir.path().join("absent.toml")) + .env("TINYCLOUD_ADDRESS", "127.0.0.1") + .env("TINYCLOUD_PORT", port.to_string()) + .env("TINYCLOUD_LOG_LEVEL", "normal") + .env("TINYCLOUD_LOG__FORMAT", "Json") + .envs(extra_env.iter().copied()) + .env("TINYCLOUD_TELEMETRY__ENABLED", "true") + .env("TINYCLOUD_PROMETHEUS__PORT", free_port().to_string()) + .env("TINYCLOUD_STORAGE__DATADIR", dir.path().join("data")) + .env("TINYCLOUD_STORAGE__DATABASE", &database) + .env("TINYCLOUD_KEYS__TYPE", "static") + .env("TINYCLOUD_KEYS__SECRET", STATIC_SECRET) + .env("NODE_STATS_READ_TOKEN", &token) + .env("TINYCLOUD_ADMIN_SECRET", &admin_secret) + .stdin(Stdio::null()) + .stdout(File::create(dir.path().join("stdout.log")).unwrap()) + .stderr(File::create(dir.path().join("stderr.log")).unwrap()) + .spawn() + .unwrap(); + let mut server = KillOnDrop(server); + + let client = reqwest::blocking::Client::new(); + wait_until_healthy(&client, &base, &mut server.0); + + // The node has migrated the file by now; add the data the route will list. + tokio::runtime::Runtime::new() + .unwrap() + .block_on(seed(&database, &space)); + + let stats = |authorization: Option<&str>| { + let mut request = client.get(format!("{base}/admin/stats")); + if let Some(authorization) = authorization { + request = request.header("Authorization", authorization); + } + request.send().unwrap() + }; + let ok = stats(Some(&format!("Bearer {token}"))); + assert_eq!(ok.status(), 200); + let body = ok.text().unwrap(); + assert!( + body.contains(&format!("0x{wallet}")), + "the route must return the seeded owner, or this test proves nothing: {body}" + ); + assert_eq!(stats(Some(&format!("Bearer {wrong_token}"))).status(), 401); + assert_eq!(stats(Some(&format!("Bearer {admin_secret}"))).status(), 401); + assert_eq!(stats(None).status(), 401); + + // Rocket and the request-logging fairing both write before the response + // is sent; a short pause covers any stdout buffering. + std::thread::sleep(Duration::from_millis(200)); + drop(server); + let logs = read_logs(dir.path()); + + for (what, needle) in [ + ("stats token", token.as_str()), + ("admin secret", admin_secret.as_str()), + ("presented wrong token", wrong_token.as_str()), + ("owner address", wallet.as_str()), + ("space id", space.as_str()), + ] { + let leaked: Vec<&str> = logs.lines().filter(|line| line.contains(needle)).collect(); + assert!( + leaked.is_empty(), + "the {what} reached the log:\n{}", + leaked.join("\n") + ); + } + + // Each request is still logged once, by method and route template. + let stats_events = logs + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|event| { + event["target"] == "tinycloud::request" && event["fields"]["route"] == "/admin/stats" + }) + .count(); + assert_eq!(stats_events, 4, "one request event per call:\n{logs}"); +} diff --git a/tinycloud-node-server/tests/postgres_round_trip_budget.rs b/tinycloud-node-server/tests/postgres_round_trip_budget.rs index 96b8ea14..9c7a62be 100644 --- a/tinycloud-node-server/tests/postgres_round_trip_budget.rs +++ b/tinycloud-node-server/tests/postgres_round_trip_budget.rs @@ -1095,6 +1095,136 @@ async fn postgres_round_trip_budget() { ); } +/// TC-1482: `GET /admin/stats` answers from one statement however many spaces +/// the node holds. Its round trips are counted with 3 spaces and again with +/// 303 and must be equal, and the window must hold exactly one statement: +/// the `/admin/usage` shape (one `store_size` call per space) would grow with +/// the space count and fail both checks. +/// +/// Measured on PostgreSQL: 2 round trips per request, the statement itself +/// and sqlx's release ping, with exactly 1 statement. +const ADMIN_STATS_BUDGET: u64 = 2; +const ADMIN_STATS_TOKEN: &str = "tc1482-round-trip-token-6e1f0b3a9c5d8274"; + +/// `count` spaces named from `first`, each owned by its own address and +/// holding one live key, inserted by two statements outside the proxy. +async fn seed_stats_spaces(seed: &impl ConnectionTrait, first: u64, count: u64) -> Result<()> { + let last = first + count - 1; + let id = "'tinycloud:pkh:eip155:1:0x' || lpad(to_hex(g), 40, '0') || ':default'"; + for sql in [ + format!("INSERT INTO space (id) SELECT {id} FROM generate_series({first}, {last}) g"), + format!( + "INSERT INTO current_kv (space, key, invocation, seq, epoch, epoch_seq, value, \ + metadata, deleted) SELECT {id}, 'k', '\\x01'::bytea, 1, '\\x02'::bytea, 1, \ + '\\x03'::bytea, '{{}}'::json, false FROM generate_series({first}, {last}) g" + ), + ] { + seed.execute(Statement::from_string(DbBackend::Postgres, sql)) + .await?; + } + Ok(()) +} + +async fn measure_stats(node: &Node, expected_owners: u64) -> Result { + let mut out = Vec::new(); + let request = node.client.get("/admin/stats").header(Header::new( + "Authorization", + format!("Bearer {ADMIN_STATS_TOKEN}"), + )); + let (status, body) = node + .measure("admin stats", "GET /admin/stats", request, &mut out) + .await?; + ensure!(status == Status::Ok, "GET /admin/stats answered {status}"); + let body: serde_json::Value = serde_json::from_str(&body)?; + ensure!( + body["count"] == expected_owners, + "expected {expected_owners} owners, the route returned {}", + body["count"] + ); + Ok(out.pop().context("no measured window")?.window) +} + +async fn admin_stats_windows(url: &str, schema: &str) -> Result<(Window, Window)> { + let (node, _datadir) = start_node(url, schema, Cut::default(), Some(POOL_SIZE)).await?; + let result = async { + node.open_pool(POOL_SIZE).await?; + let mut scoped = reqwest::Url::parse(url)?; + scoped + .query_pairs_mut() + .append_pair("options", &format!("-c search_path={schema}")); + let seed = Database::connect(scoped.as_str()).await?; + + seed_stats_spaces(&seed, 1, 3).await?; + // Prepare the statement on every pooled connection first. + for _ in 0..(2 * POOL_SIZE) { + measure_stats(&node, 3).await?; + } + let few = measure_stats(&node, 3).await?; + + seed_stats_spaces(&seed, 1_000, 300).await?; + let many = measure_stats(&node, 303).await?; + seed.close().await?; + Ok((few, many)) + } + .await; + node.pool().close().await; + result +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn postgres_admin_stats_is_one_round_trip_for_any_space_count() { + let Some(url) = postgres_test_url("postgres_admin_stats_is_one_round_trip") else { + return; + }; + let admin = Database::connect(url.as_str()) + .await + .expect("connect to PostgreSQL"); + let schema = format!( + "tc1482_rt_{}_{}", + std::process::id(), + time::OffsetDateTime::now_utc().unix_timestamp_nanos() + ); + admin + .execute(Statement::from_string( + DbBackend::Postgres, + format!("CREATE SCHEMA {schema}"), + )) + .await + .expect("create the isolated schema"); + std::env::set_var("NODE_STATS_READ_TOKEN", ADMIN_STATS_TOKEN); + let result = admin_stats_windows(&url, &schema).await; + std::env::remove_var("NODE_STATS_READ_TOKEN"); + admin + .execute(Statement::from_string( + DbBackend::Postgres, + format!("DROP SCHEMA {schema} CASCADE"), + )) + .await + .expect("drop the isolated schema"); + let (few, many) = result.expect("measure GET /admin/stats"); + + let (few_counts, many_counts) = (few.counts(), many.counts()); + let mut report = String::from("TC-1482 GET /admin/stats\n 3 spaces:\n"); + few.describe(&mut report); + let _ = writeln!(report, " 303 spaces:"); + many.describe(&mut report); + println!("{report}"); + assert_eq!( + (few_counts.round_trips, few_counts.statements), + (many_counts.round_trips, many_counts.statements), + "the round trips must not depend on the number of spaces:\n{report}" + ); + assert_eq!( + many_counts.statements, 1, + "GET /admin/stats must run exactly one statement:\n{report}" + ); + assert_eq!( + many_counts.round_trips, ADMIN_STATS_BUDGET, + "round trips changed from the budget:\n{report}" + ); + assert_eq!(many_counts.connections, 0, "opened a connection:\n{report}"); +} + /// A signed session for `account`, with the same abilities the SDK asks for. fn new_session(account: &Account) -> Result { let now = time::OffsetDateTime::now_utc(); From 3025c823aed249d6de0b19bf12148e8d23f051e4 Mon Sep 17 00:00:00 2001 From: dryrun Date: Mon, 19 Oct 2026 13:51:43 +0000 Subject: [PATCH 2/3] fix(admin): do not list owners who only signed in; 32-byte token floor (TC-1482) - data_owners: exclude database artifacts in the `account` and `secrets` spaces and KV in `account`, matched on the exact space name, in SQL before the row bound; `secrets` KV and every other space still count. The space bound now counts distinct counted spaces, not sign-in bookkeeping rows. - Replay OpenKey's sign-in bootstrap writes (SQL through SqlService, KV rows) in the core tests and assert a sign-in-only owner is not listed while an owner with a stored secret or data in another space is. - admin_stats: a token shorter than 32 bytes disables the route (404, like an unknown route); tests, README, CHANGELOG and docs/releasing.md updated. - test_support: give PostgreSQL test schemas a process-wide counter so two tests that read the same clock tick cannot collide. --- CHANGELOG.md | 2 +- README.md | 49 +- docs/releasing.md | 3 +- tinycloud-core/src/data_owners.rs | 529 +++++++++++++++++- tinycloud-core/src/test_support.rs | 8 +- .../src/routes/admin_stats.rs | 19 +- tinycloud-node-server/tests/admin_stats.rs | 41 +- 7 files changed, 592 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d6d5d7..0997faab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Changes -- Add `GET /admin/stats` (TC-1482), a read-only list of the lowercase wallet addresses that hold data on the node, for the scoreboard's protocol-user count. It is disabled (`404`, like an unknown route) unless the new `NODE_STATS_READ_TOKEN` is set; the token is separate from `TINYCLOUD_ADMIN_SECRET` (neither opens the other's routes). An owner counts when a space other than the automatic `account` space has a live `current_kv` row or any SQL/DuckDB `database_artifact` row. One SQL statement however many spaces exist, a result past 200,000 owners (or 400,000 spaces read) is a bare `507`, never a partial `200`. Neither the token nor any address is logged. The deployment workflow and compose files do not pass the variable yet. +- Add `GET /admin/stats` (TC-1482), a read-only list of the lowercase wallet addresses that hold data on the node, for the scoreboard's protocol-user count. It is disabled (`404`, like an unknown route) unless the new `NODE_STATS_READ_TOKEN` is set to a value of at least 32 bytes (`openssl rand -hex 32`); a shorter value, or one equal to `TINYCLOUD_ADMIN_SECRET`, also disables it. The token is separate from `TINYCLOUD_ADMIN_SECRET` (neither opens the other's routes). An owner counts when a space holds data that sign-in does not create by itself: the `account` space never counts, the `secrets` space counts only by its KV (its SQL table is created at sign-in), and every other space counts by a live `current_kv` row or any SQL/DuckDB `database_artifact` row, so an owner who only signed in is not listed. One SQL statement however many spaces exist. A result past 200,000 owners (or 400,000 distinct counted spaces) is a bare `507`, never a partial `200`. Neither the token nor any address is logged. The deployment workflow and compose files do not pass the variable yet. - Authorized reads no longer wait without limit for their audit record (TC-1317). Every read (KV get/list/metadata/sync, capabilities read, and the authorization step of SQL, DuckDB, space info, `/signed/kv` and `/encryption/networks`) waits for its invocation record to commit before its data is returned. That wait used to have no bound, and one serial queue served every account. - **Bounded wait.** A read waits at most `database.read_audit_wait_ms` (20 s) and is then answered `503 read_audit_busy: the read audit is backlogged; retry the request` with `Retry-After: 5`, without its data. The node refuses to start with `read_audit_wait_ms = 0`, which would answer every read busy. A batch that fails with a transient database error is now `503 unavailable_retryable` instead of 500; other batch failures stay 500, and none of them is reported as 401 any more. - **The invariant is unchanged and tested:** no read's data is returned unless its audit record has committed. diff --git a/README.md b/README.md index 307971d8..8b24b0a4 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ The SDK checks this endpoint during sign-in and requires an exact protocol versi | `POST` | `/delegate` | Yes | Create capability delegations | | `GET` | `/peer/generate/` | No | Generate space host key pair | | `GET` | `/healthz` | No | Health check | -| `GET` | `/admin/stats` | `NODE_STATS_READ_TOKEN` bearer | Distinct owner addresses that hold data (read-only; disabled unless the token is set) | +| `GET` | `/admin/stats` | `NODE_STATS_READ_TOKEN` bearer | Distinct owner addresses that hold data beyond what sign-in creates (read-only; disabled unless the token is set, at least 32 bytes) | ### Storage usage and full-space writes @@ -116,37 +116,52 @@ and 2-second connect deadlines. Read-only list of the wallet addresses that hold data on this node, for the scoreboard's protocol-user count (TC-1482). It is **disabled** (answers `404`, exactly like an unknown route) unless `NODE_STATS_READ_TOKEN` is set to a -non-blank value that differs from `TINYCLOUD_ADMIN_SECRET`. Send it as +value of at least 32 bytes that differs from `TINYCLOUD_ADMIN_SECRET`. Send it as `Authorization: Bearer `; a missing or wrong credential is a bare `401`. The token is separate from the admin secret: the admin secret does not open this route and this token opens no other `/admin/*` route. Use a -random value of at least 32 bytes, kept in the secret store; the node never +random value of at least 32 bytes (a shorter value disables the route; use +`openssl rand -hex 32`, 64 hex characters), kept in the secret store; the node never logs it or any address. ```json { "owners": ["0x…", "0x…"], "count": 2, "generatedAt": "2026-10-19T12:00:00Z" } ``` -- **What is counted.** An owner is listed when a space they own, other than the - automatic `account` registry space, holds data: at least one live - (`deleted = false`) `current_kv` row, **or** at least one SQL/DuckDB - `database_artifact` row. Artifact rows are never deleted, so a database that - was created and later emptied or dropped still counts. The `account` space - never counts, whatever it holds, so an owner whose only space is `account` - is excluded. This is stricter than "spaces with `usage_bytes > 0`" - (`/admin/usage`), which counts the `account` space's bytes. +- **What is counted.** An owner is listed when a space they own holds data that + signing in does not create by itself. OpenKey's sign-in bootstrap writes KV + in the `account` space and SQL tables in the `account` and `secrets` spaces + for every account, so those do not count: + - `account`: never counts, neither its KV nor its databases; + - `secrets`: its **databases** never count (the sign-in table), but its + **KV** does (a stored secret is real data); + - every other space counts by a live (`deleted = false`) `current_kv` row + **or** a SQL/DuckDB `database_artifact` row. Artifact rows are never + deleted, so a database that was created and later emptied or dropped still + counts. + + The match is on the exact space name (the last `:`-separated segment of the + space id), so `secrets2`, `my-account` or `Secrets` are ordinary data. An + owner who only signed in is therefore not listed. This is stricter than + "spaces with `usage_bytes > 0`" (`/admin/usage`), which counts the `account` + space's bytes. Other spaces the sign-in flow can create (`default`, + `applications`, `public`) hold no data until the user stores some. - **Owner.** Parsed from `tinycloud:pkh:eip155::0x
:`, lowercased and de-duplicated across spaces and chains. Spaces whose owner is not an `eip155` address (a `did:key` owner) are skipped. `owners` is sorted. - **Cost.** One SQL statement (one round trip) however many spaces exist; no per-space `store_size` call. For each space it makes one primary-key probe of - `current_kv`, plus one pass over `database_artifact`'s space column. + `current_kv`, plus one pass over `database_artifact`'s space column. The cost + grows with the number of `space` rows (about six per bootstrapped account), + not only with listed owners: 4.35 s warm on a local PostgreSQL 14 with + 300,000 spaces and 1.5 million keys. Re-measure before the node holds + several times that many spaces; the consumer gives up after 15 s. - **Bound, never a partial list.** The consumer makes no partial use of a response, so the route never answers a shorter list with `200`. More than - 200,000 owners (or more than 400,000 data-holding spaces to read, which - bounds memory) is a bare `507`. Every entry is exactly `0x` followed by 40 - lowercase hex digits; a space whose owner is not such an `eip155` address is - left out. + 200,000 owners (or more than 400,000 distinct counted spaces, which bounds + memory; the excluded `account` and `secrets` entries are not counted) is a + bare `507`. Every entry is exactly `0x` followed by 40 lowercase hex digits; + a space whose owner is not such an `eip155` address is left out. - **Nothing else** about spaces, keys, sizes or timing is returned, and the response is `Cache-Control: no-store`. @@ -241,7 +256,7 @@ The following common options are available: | spaces.allowlist | TINYCLOUD_SPACES_ALLOWLIST | Set the URL of an allowlist service for gating the creation of Space Peers | | telemetry.enabled | TINYCLOUD_TELEMETRY__ENABLED | Enable Prometheus latency metrics on the configured Prometheus port | | (n/a) | ROCKET_MAX_BLOCKING | Set Rocket's blocking-thread-pool cap (default 512). Note the `ROCKET_` prefix, not `TINYCLOUD_` — Rocket builds its tokio runtime from its own Figment before TinyCloud's config is loaded, so `TINYCLOUD_MAX_BLOCKING` is silently ignored for this setting | -| (n/a) | NODE_STATS_READ_TOKEN | Bearer token for the read-only `GET /admin/stats` (TC-1482). Unset, blank, or equal to `TINYCLOUD_ADMIN_SECRET` disables the route (`404`). Separate from `TINYCLOUD_ADMIN_SECRET`, which it does not replace and which does not open it. Secret: set it in the deployment secret store, never in `tinycloud.toml` or a committed env file. See [Admin stats](#admin-stats-get-adminstats) | +| (n/a) | NODE_STATS_READ_TOKEN | Bearer token for the read-only `GET /admin/stats` (TC-1482). Unset, blank, shorter than 32 bytes, or equal to `TINYCLOUD_ADMIN_SECRET` disables the route (`404`). Generate it with `openssl rand -hex 32` (64 hex characters). Separate from `TINYCLOUD_ADMIN_SECRET`, which it does not replace and which does not open it. Secret: set it in the deployment secret store, never in `tinycloud.toml` or a committed env file. See [Admin stats](#admin-stats-get-adminstats) | ### Database Config diff --git a/docs/releasing.md b/docs/releasing.md index 79e666a7..222c97f6 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -114,7 +114,8 @@ Environment settings (Settings → Environments → `production`): so the route is disabled (`404`) in a deployment until a reviewed change adds it: a `PROD_TINYCLOUD_NODE_STATS_READ_TOKEN` environment secret, the `PHALA_ENV_` pass-through and a compose `environment` line (which changes the - reviewed compose hash). Its value must differ from + reviewed compose hash). Its value must be at least 32 bytes (generate it with + `openssl rand -hex 32`; a shorter value disables the route) and must differ from `PROD_TINYCLOUD_ADMIN_SECRET`; the node disables the route if they are equal. What this blocks: a dispatch on an unlocked branch fails the environment's diff --git a/tinycloud-core/src/data_owners.rs b/tinycloud-core/src/data_owners.rs index 62aebfad..95b609fb 100644 --- a/tinycloud-core/src/data_owners.rs +++ b/tinycloud-core/src/data_owners.rs @@ -3,8 +3,17 @@ //! //! # What counts //! -//! An owner counts when at least one of their spaces other than the automatic -//! `account` registry space holds data: +//! An owner counts when at least one of their spaces holds data that sign-in +//! does not create by itself. Sign-in creates, automatically, in every +//! bootstrapped account: KV in `account` and SQL tables in `account` and +//! `secrets` (OpenKey's `tinycloud-bootstrap`). So: +//! +//! - **`account`**: never counts, neither its KV nor its database artifacts. +//! - **`secrets`**: its **KV** counts (a stored secret is real data); its +//! **database artifacts do not** (the sign-in bootstrap's table). +//! - **every other space**: KV and database artifacts both count. +//! +//! The name is the exact last segment of the space id; see [`not_named`]. //! //! - **KV**: at least one `current_kv` row with `deleted = false`; or //! - **SQL/DuckDB**: at least one `database_artifact` row. Artifact rows are @@ -36,17 +45,22 @@ use std::collections::BTreeSet; use std::str::FromStr; use tinycloud_auth::resource::SpaceId; -/// The name of the registry space OpenKey writes at sign-in. It never counts -/// on its own. +/// The registry space OpenKey writes at sign-in. It never counts on its own: +/// neither its KV nor its database artifacts. pub const ACCOUNT_SPACE: &str = "account"; +/// The secrets space. OpenKey's sign-in bootstrap creates a SQL table in it, +/// so its database artifacts never count. Its KV rows (stored secrets) do. +pub const SECRETS_SPACE: &str = "secrets"; + /// Most owners one call returns: the consumer's own limit (OpenKey refuses a /// longer list). More is an error, never a partial list. pub const MAX_OWNERS: usize = 200_000; -/// Most space ids one call reads, which bounds memory (about 110 bytes each). -/// More is an error, never a partial list. Every owner has an `account` space, -/// so this is deliberately above [`MAX_OWNERS`]. +/// Most distinct spaces one call reads, which bounds memory (about 110 bytes +/// each). Spaces that do not count (the automatic `account` space, and the +/// `secrets` space's database artifacts) are excluded in SQL and are not part +/// of this number. More is an error, never a partial list. pub const MAX_SPACES_SCANNED: usize = 400_000; /// Why no list was produced. @@ -64,18 +78,44 @@ impl From for DataOwnersError { } } -/// Space ids with live data: live KV in a `space` row, or any artifact. The -/// outer `LIMIT` is the memory bound plus one, so a result past it is -/// detected instead of silently cut. `UNION` removes the spaces both halves -/// return. Written for SQLite, PostgreSQL and MySQL; the only parameter is -/// the numeric bound, which is a constant of this crate. +/// The `WHERE` condition that is true when the space id in `column` does NOT +/// end in `:`. A space id is `tinycloud:pkh:eip155::0x
:` +/// and the id parser splits at the LAST `:`, so a name never contains one and +/// "ends in `:`" is "the name is exactly ``": `xsecrets` and an +/// owner part that happens to contain the word cannot match. `SUBSTR` of the +/// last `n` characters with `=` is used rather than `LIKE`, which is +/// ASCII-case-insensitive on SQLite (so it would drop an `Account` space that +/// the node treats as ordinary data). +fn not_named(column: &str, name: &str) -> String { + let suffix = format!(":{name}"); + format!( + "SUBSTR({column}, LENGTH({column}) - {}) <> '{suffix}'", + suffix.len() - 1 + ) +} + +/// Space ids with counted data, each at most once (`UNION`): +/// +/// - KV half: a `space` row, not named `account`, with a live `current_kv` row; +/// - artifact half: a `database_artifact` space not named `account` or `secrets`. +/// +/// The exclusions are in SQL, before the outer `LIMIT`, so the bound counts +/// DISTINCT SPACES THAT COUNT, not sign-in bookkeeping rows. The outer `LIMIT` +/// is the memory bound plus one, so a result past it is detected instead of +/// silently cut. Written for SQLite, PostgreSQL and MySQL; the only parameter +/// is the numeric bound, and the names are constants of this crate. fn query(limit: usize) -> String { + let kv_not_account = not_named("s.id", ACCOUNT_SPACE); + let artifact_not_account = not_named("a.space", ACCOUNT_SPACE); + let artifact_not_secrets = not_named("a.space", SECRETS_SPACE); format!( "SELECT u.id FROM (\ SELECT s.id AS id FROM space s \ - WHERE EXISTS (SELECT 1 FROM current_kv k WHERE k.space = s.id AND NOT k.deleted) \ + WHERE {kv_not_account} \ + AND EXISTS (SELECT 1 FROM current_kv k WHERE k.space = s.id AND NOT k.deleted) \ UNION \ - SELECT a.space AS id FROM database_artifact a\ + SELECT a.space AS id FROM database_artifact a \ + WHERE {artifact_not_account} AND {artifact_not_secrets}\ ) u LIMIT {limit}" ) } @@ -113,13 +153,11 @@ async fn collect_bounded( Ok(owners.into_iter().collect()) } -/// The lowercase owner address of a space id, or `None` for the `account` -/// space, an unparsable id, and an owner that is not an `eip155` address. +/// The lowercase owner address of a space id, or `None` for an unparsable id +/// and an owner that is not an `eip155` address. The automatic `account` and +/// `secrets` spaces are excluded by [`query`], before the row bound. fn data_owner(space_id: &str) -> Option { let space = SpaceId::from_str(space_id).ok()?; - if space.name().as_str() == ACCOUNT_SPACE { - return None; - } // `did:pkh:eip155::
` let mut parts = space.did().as_str().split(':'); let (Some("did"), Some("pkh"), Some("eip155"), Some(chain), Some(address), None) = ( @@ -339,6 +377,152 @@ mod tests { assert_eq!(result, sorted); } + /// `account` and `secrets` are matched by their exact name segment, so a + /// name that merely contains or ends with those words still counts, and + /// the exclusions drop exactly what the contract says. + async fn assert_exclusions_are_exact(conn: &DatabaseConnection) { + let owner = |n: u64| format!("0x{n:040x}"); + + // Owner 1: only KV in `secrets` (a stored secret): counted. + let secret_kv = id(1, &owner(1), "secrets"); + add_space(conn, &secret_kv).await; + add_kv(conn, &secret_kv, "vault/api-key", false).await; + // Owner 2: tombstoned KV in `secrets` only: not counted. + let secret_gone = id(1, &owner(2), "secrets"); + add_space(conn, &secret_gone).await; + add_kv(conn, &secret_gone, "vault/old", true).await; + // Owner 3: only an artifact in `secrets` (the sign-in table): not counted. + add_artifact(conn, &id(1, &owner(3), "secrets"), "sql").await; + // Owner 4: artifacts in `account` (SQL and DuckDB) and KV in `account`: not counted. + let account = id(1, &owner(4), "account"); + add_space(conn, &account).await; + add_kv(conn, &account, "bootstrap/complete", false).await; + add_artifact(conn, &account, "sql").await; + add_artifact(conn, &account, "duckdb").await; + // Owner 5: the whole sign-in footprint at once: not counted. + add_artifact(conn, &id(1, &owner(5), "secrets"), "sql").await; + add_artifact(conn, &id(1, &owner(5), "account"), "sql").await; + let account5 = id(1, &owner(5), "account"); + add_space(conn, &account5).await; + add_kv(conn, &account5, "bootstrap/complete", false).await; + assert_eq!(owners(conn).await, vec![owner(1)]); + + // Any other space counts, by KV or by artifact, whatever it is + // named next to the excluded words. + let mut expected = vec![owner(1)]; + for (n, name) in [ + "xsecrets", + "secrets2", + "my-secrets", + "secrets-old", + "Secrets", + "SECRETS", + "xaccount", + "account2", + "my-account", + "Account", + "ACCOUNT", + "default", + "applications", + "public", + ] + .into_iter() + .enumerate() + { + let who = owner(100 + n as u64); + add_artifact(conn, &id(1, &who, name), "sql").await; + expected.push(who); + let who_kv = owner(200 + n as u64); + let space = id(1, &who_kv, name); + add_space(conn, &space).await; + add_kv(conn, &space, "k", false).await; + expected.push(who_kv); + assert_eq!( + owners(conn).await.len(), + expected.len(), + "a space named {name:?} must count" + ); + } + expected.sort(); + assert_eq!(owners(conn).await, expected); + + // An artifact in `secrets` beside a live KV secret: counted by the KV. + // An owner with `secrets` artifact and a real artifact elsewhere: counted. + let both = id(1, &owner(6), "secrets"); + add_artifact(conn, &both, "sql").await; + add_space(conn, &both).await; + add_kv(conn, &both, "vault/token", false).await; + add_artifact(conn, &id(1, &owner(7), "secrets"), "sql").await; + add_artifact(conn, &id(1, &owner(7), "notes"), "sql").await; + let result = owners(conn).await; + assert!(result.contains(&owner(6)) && result.contains(&owner(7))); + assert_eq!(result.len(), expected.len() + 2); + } + + #[tokio::test] + async fn sqlite_exclusions_are_exact() { + assert_exclusions_are_exact(&sqlite().await).await; + } + + #[tokio::test] + async fn postgres_exclusions_are_exact() { + let Some(schema) = + crate::test_support::PostgresTestSchema::new("postgres_exclusions_are_exact").await + else { + return; + }; + assert_exclusions_are_exact(&schema.conn).await; + } + + /// The space bound counts distinct spaces that count, not sign-in rows: + /// any number of sign-in-only accounts leaves it untouched. + async fn assert_bound_counts_distinct_counted_spaces(conn: &DatabaseConnection) { + for n in 0..20u64 { + let who = format!("0x{n:040x}"); + let account = id(1, &who, "account"); + add_space(conn, &account).await; + add_kv(conn, &account, "bootstrap/complete", false).await; + add_artifact(conn, &account, "sql").await; + add_artifact(conn, &id(1, &who, "secrets"), "sql").await; + } + // 20 signed-in accounts, no counted space: fits even a bound of 1. + assert!(collect_bounded(conn, 1, 1).await.unwrap().is_empty()); + + // One owner whose KV and artifact are in the SAME space is one space. + let real = id(1, &format!("0x{:040x}", 99), "default"); + add_space(conn, &real).await; + add_kv(conn, &real, "k", false).await; + add_artifact(conn, &real, "sql").await; + assert_eq!(collect_bounded(conn, 1, 1).await.unwrap().len(), 1); + + // A second counted space of the same owner is a second space: over a + // bound of 1 it is an error, never a shorter list. + let second = id(1, &format!("0x{:040x}", 99), "photos"); + add_artifact(conn, &second, "sql").await; + assert!(matches!( + collect_bounded(conn, 1, 5).await, + Err(DataOwnersError::TooLarge) + )); + assert_eq!(collect_bounded(conn, 2, 1).await.unwrap().len(), 1); + } + + #[tokio::test] + async fn sqlite_bound_counts_distinct_counted_spaces() { + assert_bound_counts_distinct_counted_spaces(&sqlite().await).await; + } + + #[tokio::test] + async fn postgres_bound_counts_distinct_counted_spaces() { + let Some(schema) = crate::test_support::PostgresTestSchema::new( + "postgres_bound_counts_distinct_counted_spaces", + ) + .await + else { + return; + }; + assert_bound_counts_distinct_counted_spaces(&schema.conn).await; + } + #[tokio::test] async fn sqlite_owner_rules() { assert_owner_rules(&sqlite().await).await; @@ -393,6 +577,302 @@ mod tests { assert_bounds_fail_instead_of_truncating(&schema.conn).await; } + // ---- Sign-in replay ------------------------------------------------- + // + // What a fresh account gets from OpenKey's sign-in bootstrap + // (openkey `apps/api/src/services/tinycloud-bootstrap.ts`, run against + // openkey 82edce3 / js-sdk ba66248; the SQL text is js-sdk + // `packages/bootstrap/src/index.ts` `ACCOUNT_INDEX_SCHEMA` and + // `SECRET_RECORDS_SCHEMA`), and from the SDK's default private-key / CLI + // sign-in (`TinyCloudNode.runAccountBootstrap`, which adds the + // `system/bootstrap/complete` marker). SQL goes through the real + // `SqlService` and `SeaOrmDatabaseArtifactRepository`, so the + // `database_artifact` rows are the ones the node writes, not hand-made. + // KV rows are inserted directly: the KV write path needs signed + // delegations, and `data_owners` reads only `space` and `current_kv`. + // + // If the bootstrap gains a space or table, update this replay; a + // sign-in-only account listed as an owner is the symptom this test pins. + + const ACCOUNT_INDEX_SCHEMA: [&str; 10] = [ + "CREATE TABLE IF NOT EXISTS applications (app_id TEXT PRIMARY KEY, name TEXT, \ + description TEXT, updated_at TEXT, manifest_json TEXT NOT NULL)", + "CREATE TABLE IF NOT EXISTS application_state (app_id TEXT PRIMARY KEY, \ + manifest_hash TEXT NOT NULL, indexed_at TEXT NOT NULL)", + "CREATE TABLE IF NOT EXISTS spaces (space_id TEXT PRIMARY KEY, name TEXT NOT NULL, \ + owner_did TEXT NOT NULL, type TEXT NOT NULL, permissions_json TEXT NOT NULL, \ + status TEXT NOT NULL, registered_at TEXT, updated_at TEXT NOT NULL, expires_at TEXT)", + "CREATE TABLE IF NOT EXISTS delegations (cid TEXT PRIMARY KEY, direction TEXT NOT NULL, \ + space_id TEXT NOT NULL, space_name TEXT, counterparty_did TEXT NOT NULL, \ + delegate_did TEXT NOT NULL, delegator_did TEXT, path TEXT NOT NULL, \ + actions_json TEXT NOT NULL, expiry TEXT NOT NULL, status TEXT NOT NULL, \ + created_at TEXT, updated_at TEXT NOT NULL)", + "CREATE TABLE IF NOT EXISTS sync_state (source TEXT PRIMARY KEY, \ + synced_at TEXT NOT NULL, count INTEGER NOT NULL)", + "CREATE INDEX IF NOT EXISTS idx_delegations_direction ON delegations(direction)", + "CREATE INDEX IF NOT EXISTS idx_delegations_space ON delegations(space_id)", + "CREATE INDEX IF NOT EXISTS idx_delegations_counterparty ON delegations(counterparty_did)", + "CREATE INDEX IF NOT EXISTS idx_spaces_owner ON spaces(owner_did)", + "CREATE INDEX IF NOT EXISTS idx_spaces_type ON spaces(type)", + ]; + const SECRET_RECORDS_SCHEMA: &str = + "CREATE TABLE IF NOT EXISTS secret_records (scope TEXT NOT NULL, name TEXT NOT NULL, \ + provider_id TEXT, custom_note TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, \ + last_tested TEXT, test_status TEXT, test_message TEXT, PRIMARY KEY(scope, name))"; + const BOOTSTRAP_SPACES: [&str; 5] = ["default", "applications", "account", "secrets", "public"]; + + fn space_id(owner: &str, name: &str) -> SpaceId { + id(1, owner, name).parse().unwrap() + } + + async fn sql_execute( + service: &crate::sql::SqlService, + owner: &str, + space: &str, + database: &str, + ability: &str, + request: crate::sql::SqlRequest, + ) { + service + .execute( + &space_id(owner, space), + database, + request, + None, + ability.to_string(), + ) + .await + .unwrap_or_else(|error| panic!("{space}/{database}: {error:?}")); + } + + /// The writes of one sign-in, in the bootstrap's order. + async fn replay_sign_in( + conn: &DatabaseConnection, + service: &crate::sql::SqlService, + owner: &str, + ) { + use crate::sql::{types::SqlStatement, SqlRequest, SqlValue}; + + // 1-2. Host the five spaces and activate their sessions: `space` rows, + // no KV and no SQL. + for name in BOOTSTRAP_SPACES { + add_space(conn, &id(1, owner, name)).await; + } + // 3. The registry tables, in the `account` database of `account` + // (`tinycloud.sql/schema`; the carrier statement is the last one). + sql_execute( + service, + owner, + "account", + "account", + "tinycloud.sql/schema", + SqlRequest::Execute { + sql: ACCOUNT_INDEX_SCHEMA[9].to_string(), + params: vec![], + schema: Some(ACCOUNT_INDEX_SCHEMA.iter().map(|s| s.to_string()).collect()), + }, + ) + .await; + // 4a. KV in `account`: one `spaces/` entry per space, the secrets + // application manifest, and the SDK's `system/bootstrap/complete` marker. + for name in BOOTSTRAP_SPACES { + let key = format!("spaces/{}", id(1, owner, name)); + add_kv(conn, &id(1, owner, "account"), &key, false).await; + } + add_kv( + conn, + &id(1, owner, "account"), + "applications/xyz.tinycloud.secrets", + false, + ) + .await; + add_kv( + conn, + &id(1, owner, "account"), + "system/bootstrap/complete", + false, + ) + .await; + // 4b. The same five spaces as rows of the `spaces` table. + sql_execute( + service, + owner, + "account", + "account", + "tinycloud.sql/write", + SqlRequest::Batch { + statements: BOOTSTRAP_SPACES + .iter() + .map(|name| SqlStatement { + sql: "INSERT OR REPLACE INTO spaces (space_id, name, owner_did, type, \ + permissions_json, status, registered_at, updated_at, expires_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + .to_string(), + params: vec![ + SqlValue::Text(id(1, owner, name)), + SqlValue::Text(name.to_string()), + SqlValue::Text(format!("did:pkh:eip155:1:{owner}")), + SqlValue::Text("owned".into()), + SqlValue::Text("[\"*\"]".into()), + SqlValue::Text("active".into()), + SqlValue::Text("now".into()), + SqlValue::Text("now".into()), + SqlValue::Null, + ], + }) + .collect(), + }, + ) + .await; + // 5. The secrets-records table, in database `default` of `secrets`. + sql_execute( + service, + owner, + "secrets", + "default", + "tinycloud.sql/schema", + SqlRequest::Execute { + sql: SECRET_RECORDS_SCHEMA.to_string(), + params: vec![], + schema: Some(vec![SECRET_RECORDS_SCHEMA.to_string()]), + }, + ) + .await; + } + + async fn count(conn: &DatabaseConnection, table: &str, space: &str) -> i64 { + let sql = format!( + "SELECT COUNT(*) AS n FROM {table} WHERE space = {}", + placeholders(conn, 1) + ); + conn.query_one(Statement::from_sql_and_values( + conn.get_database_backend(), + &sql, + vec![space.into()], + )) + .await + .unwrap() + .unwrap() + .try_get::("", "n") + .unwrap() + } + + /// Owners that only signed in are not listed; owners that also stored + /// anything outside what sign-in writes are. + async fn assert_sign_in_only_owners_are_not_listed(conn: &DatabaseConnection) { + use crate::{database_artifacts::SeaOrmDatabaseArtifactRepository, sql::SqlService}; + + let cache = tempfile::TempDir::new().unwrap(); + let service = SqlService::new( + cache.path().to_string_lossy().into_owned(), + 0, + std::sync::Arc::new(SeaOrmDatabaseArtifactRepository::new(conn.clone())), + ); + let signed_in = |n: u64| format!("0x{:040x}", 0xa000 + n); + let (only, only_sdk, secret_kv, other_kv, other_sql, vault_public) = ( + signed_in(1), + signed_in(2), + signed_in(3), + signed_in(4), + signed_in(5), + signed_in(6), + ); + for owner in [ + &only, + &only_sdk, + &secret_kv, + &other_kv, + &other_sql, + &vault_public, + ] { + replay_sign_in(conn, &service, owner).await; + } + + // The replay really wrote what a sign-in writes: the account and the + // secrets database (artifact rows) and the account's KV. Without the + // exclusions every one of these owners would be listed. + for owner in [&only, &secret_kv] { + assert_eq!( + count(conn, "database_artifact", &id(1, owner, "account")).await, + 1 + ); + assert_eq!( + count(conn, "database_artifact", &id(1, owner, "secrets")).await, + 1 + ); + assert_eq!(count(conn, "current_kv", &id(1, owner, "account")).await, 7); + } + let raw: BTreeSet = conn + .query_all(Statement::from_string( + conn.get_database_backend(), + "SELECT space FROM database_artifact UNION SELECT space FROM current_kv" + .to_string(), + )) + .await + .unwrap() + .iter() + .map(|row| row.try_get_by_index::(0).unwrap()) + .collect(); + assert!(raw.contains(&id(1, &only, "secrets")) && raw.contains(&id(1, &only, "account"))); + assert!( + owners(conn).await.is_empty(), + "an owner who only signed in must not be listed: {:?}", + owners(conn).await + ); + + // A stored secret: KV in `secrets`. + let secrets = id(1, &secret_kv, "secrets"); + add_kv(conn, &secrets, "vault/openai", false).await; + assert_eq!(owners(conn).await, vec![secret_kv.clone()]); + + // Data in another space: KV in `default`, a SQL table in `default`, + // and the vault's public-space KV (written when the user unlocks the + // vault; counted, like any other space). + add_kv(conn, &id(1, &other_kv, "default"), "notes/a", false).await; + sql_execute( + &service, + &other_sql, + "default", + "main", + "tinycloud.sql/write", + crate::sql::SqlRequest::Execute { + sql: "CREATE TABLE notes (id INTEGER)".to_string(), + params: vec![], + schema: None, + }, + ) + .await; + add_kv( + conn, + &id(1, &vault_public, "public"), + ".well-known/vault-pubkey", + false, + ) + .await; + let mut expected = vec![secret_kv, other_kv, other_sql, vault_public]; + expected.sort(); + assert_eq!(owners(conn).await, expected); + assert!(!owners(conn).await.contains(&only)); + assert!(!owners(conn).await.contains(&only_sdk)); + } + + #[tokio::test] + async fn sqlite_sign_in_only_owners_are_not_listed() { + assert_sign_in_only_owners_are_not_listed(&sqlite().await).await; + } + + #[tokio::test] + async fn postgres_sign_in_only_owners_are_not_listed() { + let Some(schema) = crate::test_support::PostgresTestSchema::new( + "postgres_sign_in_only_owners_are_not_listed", + ) + .await + else { + return; + }; + assert_sign_in_only_owners_are_not_listed(&schema.conn).await; + } + #[test] fn owner_parsing() { let lower = "0x0abcdefabcdefabcdefabcdefabcdefabcdefabc"; @@ -404,14 +884,9 @@ mod tests { data_owner("tinycloud:pkh:eip155:10:0x0ABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABC:x"), Some(lower.to_string()) ); - // The registry space, exactly named, is the only exclusion; a longer or - // differently cased name is data. - assert_eq!( - data_owner(&format!("tinycloud:pkh:eip155:1:{lower}:account")), - None - ); - assert!(data_owner(&format!("tinycloud:pkh:eip155:1:{lower}:account2")).is_some()); - assert!(data_owner(&format!("tinycloud:pkh:eip155:1:{lower}:Account")).is_some()); + // The `account` / `secrets` exclusions are in the SQL, not here (see + // `assert_exclusions_are_exact`). + assert!(data_owner(&format!("tinycloud:pkh:eip155:1:{lower}:account")).is_some()); // Not a 40-digit eip155 address owner. assert_eq!( data_owner("tinycloud:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK:default"), diff --git a/tinycloud-core/src/test_support.rs b/tinycloud-core/src/test_support.rs index ca54a65c..103e3304 100644 --- a/tinycloud-core/src/test_support.rs +++ b/tinycloud-core/src/test_support.rs @@ -57,10 +57,14 @@ impl PostgresTestSchema { use sea_orm_migration::MigratorTrait; let url = postgres_test_url(test)?; + // Two tests of one process can read the same clock tick, so the name also + // carries a process-wide counter. + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let schema = format!( - "tc_test_{}_{}", + "tc_test_{}_{}_{}", std::process::id(), - time::OffsetDateTime::now_utc().unix_timestamp_nanos() + time::OffsetDateTime::now_utc().unix_timestamp_nanos(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) ); let admin = Database::connect(sea_orm::ConnectOptions::new(url.clone())) .await diff --git a/tinycloud-node-server/src/routes/admin_stats.rs b/tinycloud-node-server/src/routes/admin_stats.rs index b679528e..9363028e 100644 --- a/tinycloud-node-server/src/routes/admin_stats.rs +++ b/tinycloud-node-server/src/routes/admin_stats.rs @@ -12,8 +12,9 @@ //! - if both are set to the same value, this route is disabled, so one value //! can never open both surfaces. //! -//! When the token is unset, empty or whitespace-only the route is disabled and -//! answers exactly as an unknown route does (a 404 from the default catcher), +//! When the token is unset, empty, whitespace-only or shorter than 32 bytes the +//! route is disabled and answers exactly as an unknown route does (a 404 from +//! the default catcher), //! whatever the request carries. A missing, malformed or wrong credential is a //! bare 401 with no detail. The comparison hashes both sides to fixed-length //! SHA-256 digests and compares those in constant time, so neither the content @@ -60,11 +61,16 @@ fn secrets_match(a: &str, b: &str) -> bool { .into() } +/// The shortest token the route accepts, in bytes. `openssl rand -hex 32` +/// (64 characters) is the recommended generator. +pub const MIN_TOKEN_BYTES: usize = 32; + /// The configured stats token, or `None` when the route is disabled: unset, -/// empty or whitespace-only, not valid UTF-8, or equal to the admin secret. +/// empty or whitespace-only, shorter than [`MIN_TOKEN_BYTES`] bytes (after +/// trimming), not valid UTF-8, or equal to the admin secret. fn configured_token() -> Option { let token = std::env::var(STATS_TOKEN_ENV).ok()?; - if token.trim().is_empty() { + if token.trim().len() < MIN_TOKEN_BYTES { return None; } if let Ok(admin) = std::env::var("TINYCLOUD_ADMIN_SECRET") { @@ -120,8 +126,9 @@ impl<'r, 'o: 'r, R: Responder<'r, 'o>> Responder<'r, 'o> for NoStore { } } -/// The distinct owner addresses that hold data beyond the automatic `account` -/// space. One aggregate statement; see `tinycloud_core::data_owners`. +/// The distinct owner addresses that hold data beyond what sign-in creates by +/// itself (`account`, and the `secrets` space's database artifacts). One +/// aggregate statement; see `tinycloud_core::data_owners`. #[get("/admin/stats")] pub async fn get_stats( _auth: StatsAuth, diff --git a/tinycloud-node-server/tests/admin_stats.rs b/tinycloud-node-server/tests/admin_stats.rs index 3822fedf..66528ca4 100644 --- a/tinycloud-node-server/tests/admin_stats.rs +++ b/tinycloud-node-server/tests/admin_stats.rs @@ -20,6 +20,7 @@ use tinycloud_core::sea_orm::{ }; const STATS_TOKEN: &str = "tc1482-stats-token-3f9a1c7e5b2d4a60"; +const MIN_TOKEN_BYTES: usize = 32; const ADMIN_SECRET: &str = "tc1482-admin-secret-9d8c7b6a5f4e3d2c"; const TOKEN_ENV: &str = "NODE_STATS_READ_TOKEN"; const ADMIN_ENV: &str = "TINYCLOUD_ADMIN_SECRET"; @@ -29,6 +30,8 @@ const ALICE_MIXED: &str = "0x0ABCDEFabcdefABCDEFabcdefABCDEFabcdefABC"; const BOB: &str = "0x1111111111111111111111111111111111111111"; const CAROL: &str = "0x2222222222222222222222222222222222222222"; const DAVE: &str = "0x3333333333333333333333333333333333333333"; +const EVE: &str = "0x4444444444444444444444444444444444444444"; +const FRANK: &str = "0x5555555555555555555555555555555555555555"; /// The two secrets live in the process environment, which the guards read on /// every request, so tests that set them run one at a time. @@ -361,6 +364,8 @@ async fn assert_stats_scenario(backend: Backend, test: &str) { node.add_kv(&alice_account, "bootstrap/complete", false) .await; node.add_artifact(&alice_account).await; + // Frank: a SQL table in `secrets` only (what sign-in creates there). + node.add_artifact(&space(1, FRANK, "secrets")).await; // Bob: a data space whose only rows are deleted. let bob = space(1, BOB, "default"); node.add_space(&bob).await; @@ -383,14 +388,20 @@ async fn assert_stats_scenario(backend: Backend, test: &str) { node.add_space(&dave).await; node.add_kv(&dave, "old", true).await; node.add_kv(&dave, "new", false).await; + // Eve stored a secret (KV in `secrets`) and also has the sign-in table + // there. + let eve = space(1, EVE, "secrets"); + node.add_space(&eve).await; + node.add_kv(&eve, "vault/api-key", false).await; + node.add_artifact(&eve).await; let reply = node.stats(Some(&auth)).await; assert_eq!(reply.status, Status::Ok); assert_eq!(reply.content_type.as_deref(), Some("application/json")); assert_eq!(reply.cache_control.as_deref(), Some("no-store")); let body = reply.json(); - assert_eq!(body["owners"], json!([ALICE, CAROL, DAVE])); - assert_eq!(body["count"], 3); + assert_eq!(body["owners"], json!([ALICE, CAROL, DAVE, EVE])); + assert_eq!(body["count"], 4); let mut keys: Vec<&String> = body.as_object().unwrap().keys().collect(); keys.sort(); assert_eq!(keys, ["count", "generatedAt", "owners"]); @@ -546,15 +557,26 @@ async fn disabled_route_is_an_unknown_route() { ) }; - // Unset, empty, whitespace-only and equal-to-the-admin-secret all disable - // the route, whatever credential the request carries. - let disabled: [Option<&str>; 4] = [None, Some(""), Some(" \t\n"), Some(ADMIN_SECRET)]; + // Unset, empty, whitespace-only, shorter than 32 bytes (31 is too short) and + // equal-to-the-admin-secret all disable the route, whatever credential the + // request carries. + let short = "a".repeat(MIN_TOKEN_BYTES - 1); + let padded_short = format!("{short} \n"); + let disabled: [Option<&str>; 6] = [ + None, + Some(""), + Some(" \t\n"), + Some(&short), + Some(&padded_short), + Some(ADMIN_SECRET), + ]; for value in disabled { env.stats(value); for authorization in [ None, Some(bearer(STATS_TOKEN)), Some(bearer(ADMIN_SECRET)), + Some(bearer(&short)), Some(bearer("")), ] { let reply = node.stats(authorization.as_deref()).await; @@ -566,6 +588,15 @@ async fn disabled_route_is_an_unknown_route() { } } + // Exactly 32 bytes is enough. + let minimum = "b".repeat(MIN_TOKEN_BYTES); + env.stats(Some(&minimum)); + assert_eq!(node.stats(Some(&bearer(&minimum))).await.status, Status::Ok); + assert_eq!( + node.stats(Some(&bearer(&short))).await.status, + Status::Unauthorized + ); + // Enabling it takes effect for the next request; the admin secret is // still refused and the token accepted. env.stats(Some(STATS_TOKEN)); From e6ed4755a20b7bde54086a9ad2e7a9ebb2c9a94a Mon Sep 17 00:00:00 2001 From: dryrun Date: Mon, 19 Oct 2026 14:22:34 +0000 Subject: [PATCH 3/3] admin stats: do not count vault metadata as data --- CHANGELOG.md | 2 +- README.md | 59 +++--- tinycloud-core/src/data_owners.rs | 181 ++++++++++++++---- .../src/routes/admin_stats.rs | 7 +- 4 files changed, 182 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bc0d3f8..713c1cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Changes -- Add `GET /admin/stats` (TC-1482), a read-only list of the lowercase wallet addresses that hold data on the node, for the scoreboard's protocol-user count. It is disabled (`404`, like an unknown route) unless the new `NODE_STATS_READ_TOKEN` is set to a value of at least 32 bytes (`openssl rand -hex 32`); a shorter value, or one equal to `TINYCLOUD_ADMIN_SECRET`, also disables it. The token is separate from `TINYCLOUD_ADMIN_SECRET` (neither opens the other's routes). An owner counts when a space holds data that sign-in does not create by itself: the `account` space never counts, the `secrets` space counts only by its KV (its SQL table is created at sign-in), and every other space counts by a live `current_kv` row or any SQL/DuckDB `database_artifact` row, so an owner who only signed in is not listed. One SQL statement however many spaces exist. A result past 200,000 owners (or 400,000 distinct counted spaces) is a bare `507`, never a partial `200`. Neither the token nor any address is logged. The deployment workflow and compose files do not pass the variable yet. +- Add `GET /admin/stats` (TC-1482), a read-only list of the lowercase wallet addresses that hold data on the node, for the scoreboard's protocol-user count. It is disabled (`404`, like an unknown route) unless the new `NODE_STATS_READ_TOKEN` is set to a value of at least 32 bytes (`openssl rand -hex 32`); a shorter value, or one equal to `TINYCLOUD_ADMIN_SECRET`, also disables it. The token is separate from `TINYCLOUD_ADMIN_SECRET` (neither opens the other's routes). An owner counts when they hold live KV in any space except `account`, or database artifacts in any space except `account` and `secrets`, except KV in the `public` space whose key starts with `.well-known/vault-` (vault metadata). Automatic sign-in records are excluded: the `account` space, the `secrets` table OpenKey's bootstrap creates, and the vault metadata `vault.unlock()` writes. App first-use databases (for example listen's `conversations`) count. So an owner who only signed in, or only signed in and unlocked the vault, is not listed. One SQL statement however many spaces exist. A result past 200,000 owners (or 400,000 distinct counted spaces) is a bare `507`, never a partial `200`. Neither the token nor any address is logged. The deployment workflow and compose files do not pass the variable yet. - `POST /revoke` authorizes the delegation's issuer, the owner of a space its abilities target, or the holder of a revoke control grant issued by one of them. The delegatee alone is no longer an authorized revoker, directly or through a control grant it issued; an intermediate delegator still revokes the delegations it issued. Delegation status visibility is unchanged (TC-1436). - Revoke and status control grants may name an encryption network (`urn:tinycloud:encryption::`) owned by the control's grantor. Only the network's owner can issue such a control without a parent, because the owner is already the network's root authority (`/delegate` accepted this before); naming the network in a control grants no decrypt or other network ability, so this is not an escalation. Each resource of a control is typed as an exact `urn:cid:`, a `/delegation` root, or a network; any other resource is ignored. A control covers a target only when every one of the target's resources is covered: a space resource by a named space, a network resource by the same parsed network id (owner DID including chain id, and name), so lowercase and EIP-55 checksummed spellings of an address name the same network, and a resource that does not parse as a network is never covered. A control that names a network never counts as an exact-CID control, and a network named by a control issued by someone other than its owner is ignored. A control such as `/delegation` + `/delegation` + `:share` therefore revokes or reads the status of share roots that carry `:share` decrypt, but not grants on `:default` or any other network (TC-1423). diff --git a/README.md b/README.md index 8b24b0a4..4e10dbb2 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ The SDK checks this endpoint during sign-in and requires an exact protocol versi | `POST` | `/delegate` | Yes | Create capability delegations | | `GET` | `/peer/generate/` | No | Generate space host key pair | | `GET` | `/healthz` | No | Health check | -| `GET` | `/admin/stats` | `NODE_STATS_READ_TOKEN` bearer | Distinct owner addresses that hold data beyond what sign-in creates (read-only; disabled unless the token is set, at least 32 bytes) | +| `GET` | `/admin/stats` | `NODE_STATS_READ_TOKEN` bearer | Distinct owner addresses that hold data beyond automatic sign-in records and vault metadata (read-only; disabled unless the token is set, at least 32 bytes) | ### Storage usage and full-space writes @@ -128,24 +128,36 @@ logs it or any address. { "owners": ["0x…", "0x…"], "count": 2, "generatedAt": "2026-10-19T12:00:00Z" } ``` -- **What is counted.** An owner is listed when a space they own holds data that - signing in does not create by itself. OpenKey's sign-in bootstrap writes KV - in the `account` space and SQL tables in the `account` and `secrets` spaces - for every account, so those do not count: - - `account`: never counts, neither its KV nor its databases; - - `secrets`: its **databases** never count (the sign-in table), but its - **KV** does (a stored secret is real data); - - every other space counts by a live (`deleted = false`) `current_kv` row - **or** a SQL/DuckDB `database_artifact` row. Artifact rows are never - deleted, so a database that was created and later emptied or dropped still - counts. - - The match is on the exact space name (the last `:`-separated segment of the - space id), so `secrets2`, `my-account` or `Secrets` are ordinary data. An - owner who only signed in is therefore not listed. This is stricter than - "spaces with `usage_bytes > 0`" (`/admin/usage`), which counts the `account` - space's bytes. Other spaces the sign-in flow can create (`default`, - `applications`, `public`) hold no data until the user stores some. +- **What is counted** (the complete rule). + - **Counts:** an owner holding live (`deleted = false`) KV in any space + except `account`, or a SQL/DuckDB database artifact in any space except + `account` and `secrets`, **except** KV in the `public` space whose key + starts with `.well-known/vault-` (vault metadata). + - **Excluded** (automatic sign-in records, not use of the node): the + `account` space entirely (its KV and its databases); the `secrets` table + that OpenKey's bootstrap creates (database artifacts in `secrets`; KV + stored in `secrets` still counts); and the vault metadata that + `vault.unlock()` writes (`.well-known/vault-pubkey`, + `.well-known/vault-version`, `.well-known/vault-space` in `public`). An + app may unlock the vault automatically, so counting those rows would + inflate "users with data" the same way the `secrets` table did. + - **App first-use data counts.** A database an app creates on first use + (for example listen's `conversations` database or feed-learn's tables) is + a person actually using an app that stores on TinyCloud. Artifact rows are + never deleted, so a database that was created and later emptied or dropped + still counts. + + The space match is on the exact space name (the last `:`-separated segment + of the space id), so `secrets2`, `my-account`, `Secrets` or `Account` are + ordinary spaces. The vault exclusion is exactly the `public` space and keys + that start with `.well-known/vault-` (case-sensitive, starts-with): other + `.well-known/*` keys (including `.well-known/vault`), any other KV in + `public`, and the same prefix in any other space all count, and a `public` + space with at least one live KV row that is not vault metadata makes its + owner count. An owner who only signed in, or only signed in and unlocked the + vault, is therefore not listed. This is stricter than "spaces with + `usage_bytes > 0`" (`/admin/usage`), which counts the `account` space's + bytes. - **Owner.** Parsed from `tinycloud:pkh:eip155::0x
:`, lowercased and de-duplicated across spaces and chains. Spaces whose owner is not an `eip155` address (a `did:key` owner) are skipped. `owners` is sorted. @@ -154,12 +166,15 @@ logs it or any address. `current_kv`, plus one pass over `database_artifact`'s space column. The cost grows with the number of `space` rows (about six per bootstrapped account), not only with listed owners: 4.35 s warm on a local PostgreSQL 14 with - 300,000 spaces and 1.5 million keys. Re-measure before the node holds - several times that many spaces; the consumer gives up after 15 s. + 300,000 spaces and 1.5 million keys (measured before the vault-metadata + condition, which adds one prefix comparison per `current_kv` row a probe + reads; in a `public` space that is at most the vault rows before the first + other live row). Re-measure before the node holds several times that many + spaces; the consumer gives up after 15 s. - **Bound, never a partial list.** The consumer makes no partial use of a response, so the route never answers a shorter list with `200`. More than 200,000 owners (or more than 400,000 distinct counted spaces, which bounds - memory; the excluded `account` and `secrets` entries are not counted) is a + memory; spaces that do not count are not included) is a bare `507`. Every entry is exactly `0x` followed by 40 lowercase hex digits; a space whose owner is not such an `eip155` address is left out. - **Nothing else** about spaces, keys, sizes or timing is returned, and the diff --git a/tinycloud-core/src/data_owners.rs b/tinycloud-core/src/data_owners.rs index 95b609fb..c40625fc 100644 --- a/tinycloud-core/src/data_owners.rs +++ b/tinycloud-core/src/data_owners.rs @@ -4,16 +4,24 @@ //! # What counts //! //! An owner counts when at least one of their spaces holds data that sign-in -//! does not create by itself. Sign-in creates, automatically, in every -//! bootstrapped account: KV in `account` and SQL tables in `account` and -//! `secrets` (OpenKey's `tinycloud-bootstrap`). So: +//! and vault unlock do not create by themselves. Sign-in creates, +//! automatically, in every bootstrapped account: KV in `account` and SQL +//! tables in `account` and `secrets` (OpenKey's `tinycloud-bootstrap`). +//! `vault.unlock()` writes three KV rows in `public` whose keys start with +//! `.well-known/vault-` (`vault-pubkey`, `vault-version`, `vault-space`), and +//! an app may unlock automatically. So: //! //! - **`account`**: never counts, neither its KV nor its database artifacts. //! - **`secrets`**: its **KV** counts (a stored secret is real data); its //! **database artifacts do not** (the sign-in bootstrap's table). +//! - **`public`**: its KV counts, except rows whose key starts with the exact, +//! case-sensitive prefix `.well-known/vault-` (vault metadata). Any other +//! `public` row, and the same prefix in any other space, still counts. //! - **every other space**: KV and database artifacts both count. //! -//! The name is the exact last segment of the space id; see [`not_named`]. +//! The name is the exact last segment of the space id; see [`name_test`]. +//! App first-use data (a database an app creates, such as a `conversations` +//! table) counts: it means a person used an app that stores on TinyCloud. //! //! - **KV**: at least one `current_kv` row with `deleted = false`; or //! - **SQL/DuckDB**: at least one `database_artifact` row. Artifact rows are @@ -53,14 +61,24 @@ pub const ACCOUNT_SPACE: &str = "account"; /// so its database artifacts never count. Its KV rows (stored secrets) do. pub const SECRETS_SPACE: &str = "secrets"; +/// The `public` space, where `vault.unlock()` writes its metadata. +pub const PUBLIC_SPACE: &str = "public"; + +/// Keys in the `public` space that start with this exact prefix are vault +/// metadata (`.well-known/vault-pubkey`, `.well-known/vault-version`, +/// `.well-known/vault-space`), not data. ASCII only, so its byte length is its +/// character length in SQL `SUBSTR`. +pub const VAULT_METADATA_PREFIX: &str = ".well-known/vault-"; + /// Most owners one call returns: the consumer's own limit (OpenKey refuses a /// longer list). More is an error, never a partial list. pub const MAX_OWNERS: usize = 200_000; /// Most distinct spaces one call reads, which bounds memory (about 110 bytes -/// each). Spaces that do not count (the automatic `account` space, and the -/// `secrets` space's database artifacts) are excluded in SQL and are not part -/// of this number. More is an error, never a partial list. +/// each). Spaces that do not count (the automatic `account` space, the +/// `secrets` space's database artifacts, and a `public` space holding only +/// vault metadata) are excluded in SQL and are not part of this number. More +/// is an error, never a partial list. pub const MAX_SPACES_SCANNED: usize = 400_000; /// Why no list was produced. @@ -78,41 +96,60 @@ impl From for DataOwnersError { } } -/// The `WHERE` condition that is true when the space id in `column` does NOT -/// end in `:`. A space id is `tinycloud:pkh:eip155::0x
:` -/// and the id parser splits at the LAST `:`, so a name never contains one and -/// "ends in `:`" is "the name is exactly ``": `xsecrets` and an -/// owner part that happens to contain the word cannot match. `SUBSTR` of the -/// last `n` characters with `=` is used rather than `LIKE`, which is -/// ASCII-case-insensitive on SQLite (so it would drop an `Account` space that -/// the node treats as ordinary data). -fn not_named(column: &str, name: &str) -> String { +/// `SUBSTR` of the last `name.len() + 1` characters of the space id in +/// `column`, compared with `op` (`=` or `<>`) to `':'`. A space id is +/// `tinycloud:pkh:eip155::0x
:` and the id parser splits +/// at the LAST `:`, so a name never contains one and "ends in `:`" is +/// "the name is exactly ``": `xsecrets` and an owner part that happens to +/// contain the word cannot match. `SUBSTR` with `=` is used rather than +/// `LIKE`, which is ASCII-case-insensitive on SQLite (so it would drop an +/// `Account` space that the node treats as ordinary data). `:public` is 7 +/// characters, so its substring starts at `LENGTH - 6`. +fn name_test(column: &str, name: &str, op: &str) -> String { let suffix = format!(":{name}"); format!( - "SUBSTR({column}, LENGTH({column}) - {}) <> '{suffix}'", + "SUBSTR({column}, LENGTH({column}) - {}) {op} '{suffix}'", suffix.len() - 1 ) } +/// The `WHERE` condition that is true when the space id in `column` does NOT +/// end in `:`. +fn not_named(column: &str, name: &str) -> String { + name_test(column, name, "<>") +} + +/// The condition that is true when the space id in `column` ends in `:`. +fn named(column: &str, name: &str) -> String { + name_test(column, name, "=") +} + /// Space ids with counted data, each at most once (`UNION`): /// -/// - KV half: a `space` row, not named `account`, with a live `current_kv` row; +/// - KV half: a `space` row, not named `account`, with a live `current_kv` row +/// that is not vault metadata (in a space named `public`, a key starting +/// with [`VAULT_METADATA_PREFIX`]); /// - artifact half: a `database_artifact` space not named `account` or `secrets`. /// /// The exclusions are in SQL, before the outer `LIMIT`, so the bound counts /// DISTINCT SPACES THAT COUNT, not sign-in bookkeeping rows. The outer `LIMIT` /// is the memory bound plus one, so a result past it is detected instead of /// silently cut. Written for SQLite, PostgreSQL and MySQL; the only parameter -/// is the numeric bound, and the names are constants of this crate. +/// is the numeric bound, and the names and prefix are constants of this crate +/// (no `_`, `%` or quote in them, so inlining them is exact). fn query(limit: usize) -> String { let kv_not_account = not_named("s.id", ACCOUNT_SPACE); + let kv_in_public = named("s.id", PUBLIC_SPACE); + let prefix = VAULT_METADATA_PREFIX; + let prefix_len = prefix.len(); let artifact_not_account = not_named("a.space", ACCOUNT_SPACE); let artifact_not_secrets = not_named("a.space", SECRETS_SPACE); format!( "SELECT u.id FROM (\ SELECT s.id AS id FROM space s \ WHERE {kv_not_account} \ - AND EXISTS (SELECT 1 FROM current_kv k WHERE k.space = s.id AND NOT k.deleted) \ + AND EXISTS (SELECT 1 FROM current_kv k WHERE k.space = s.id AND NOT k.deleted \ + AND NOT ({kv_in_public} AND SUBSTR(k.key, 1, {prefix_len}) = '{prefix}')) \ UNION \ SELECT a.space AS id FROM database_artifact a \ WHERE {artifact_not_account} AND {artifact_not_secrets}\ @@ -769,22 +806,32 @@ mod tests { std::sync::Arc::new(SeaOrmDatabaseArtifactRepository::new(conn.clone())), ); let signed_in = |n: u64| format!("0x{:040x}", 0xa000 + n); - let (only, only_sdk, secret_kv, other_kv, other_sql, vault_public) = ( + let (only, only_sdk, secret_kv, other_kv, other_sql) = ( signed_in(1), signed_in(2), signed_in(3), signed_in(4), signed_in(5), - signed_in(6), ); - for owner in [ - &only, - &only_sdk, - &secret_kv, - &other_kv, - &other_sql, - &vault_public, - ] { + // Owners that also unlock the vault (`vault.unlock()` writes three KV + // rows in `public`), then differ in what else they hold. + let (unlock_only, unlock_notes, unlock_deleted, unlock_other_space) = + (signed_in(6), signed_in(7), signed_in(8), signed_in(9)); + let (unlock_wk_other, unlock_wk_nodash, unlock_case) = + (signed_in(10), signed_in(11), signed_in(12)); + let unlockers = [ + &unlock_only, + &unlock_notes, + &unlock_deleted, + &unlock_other_space, + &unlock_wk_other, + &unlock_wk_nodash, + &unlock_case, + ]; + for owner in [&only, &only_sdk, &secret_kv, &other_kv, &other_sql] + .into_iter() + .chain(unlockers) + { replay_sign_in(conn, &service, owner).await; } @@ -820,14 +867,31 @@ mod tests { owners(conn).await ); + // Sign-in + vault unlock only: the three `.well-known/vault-*` rows + // in `public` are present (live) but are not data. + for owner in unlockers { + let public = id(1, owner, "public"); + for key in [ + ".well-known/vault-pubkey", + ".well-known/vault-version", + ".well-known/vault-space", + ] { + add_kv(conn, &public, key, false).await; + } + assert_eq!(count(conn, "current_kv", &public).await, 3); + } + assert!( + owners(conn).await.is_empty(), + "sign-in plus vault unlock must not be listed: {:?}", + owners(conn).await + ); + // A stored secret: KV in `secrets`. let secrets = id(1, &secret_kv, "secrets"); add_kv(conn, &secrets, "vault/openai", false).await; assert_eq!(owners(conn).await, vec![secret_kv.clone()]); - // Data in another space: KV in `default`, a SQL table in `default`, - // and the vault's public-space KV (written when the user unlocks the - // vault; counted, like any other space). + // Data in another space: KV in `default`, a SQL table in `default`. add_kv(conn, &id(1, &other_kv, "default"), "notes/a", false).await; sql_execute( &service, @@ -842,18 +906,53 @@ mod tests { }, ) .await; - add_kv( - conn, - &id(1, &vault_public, "public"), - ".well-known/vault-pubkey", - false, - ) - .await; - let mut expected = vec![secret_kv, other_kv, other_sql, vault_public]; + let mut expected = vec![secret_kv, other_kv, other_sql]; expected.sort(); assert_eq!(owners(conn).await, expected); + + // Each of these makes an unlocked owner count. Checked one at a time, + // so a wider filter fails on the first row it wrongly hides. + let public = |owner: &String| id(1, owner, "public"); + let cases: [(&String, String, &str); 5] = [ + // any other `public` KV + (&unlock_notes, public(&unlock_notes), "notes/x"), + // the prefix, but in another space + ( + &unlock_other_space, + id(1, &unlock_other_space, "default"), + ".well-known/vault-pubkey", + ), + // another `.well-known/` key + ( + &unlock_wk_other, + public(&unlock_wk_other), + ".well-known/other", + ), + // `vault` without the dash + ( + &unlock_wk_nodash, + public(&unlock_wk_nodash), + ".well-known/vault", + ), + // the prefix is case-sensitive + (&unlock_case, public(&unlock_case), ".Well-Known/vault-x"), + ]; + for (owner, space, key) in cases { + let before = owners(conn).await; + assert!(!before.contains(owner), "{key} not yet written"); + add_kv(conn, &space, key, false).await; + let after = owners(conn).await; + assert!(after.contains(owner), "{key} in {space} must count"); + assert_eq!(after.len(), before.len() + 1); + } + + // A deleted `public` row that is not metadata does not count. + add_kv(conn, &public(&unlock_deleted), "notes/gone", true).await; + assert!(!owners(conn).await.contains(&unlock_deleted)); + assert!(!owners(conn).await.contains(&only)); assert!(!owners(conn).await.contains(&only_sdk)); + assert!(!owners(conn).await.contains(&unlock_only)); } #[tokio::test] diff --git a/tinycloud-node-server/src/routes/admin_stats.rs b/tinycloud-node-server/src/routes/admin_stats.rs index 9363028e..37dcfaf0 100644 --- a/tinycloud-node-server/src/routes/admin_stats.rs +++ b/tinycloud-node-server/src/routes/admin_stats.rs @@ -126,9 +126,10 @@ impl<'r, 'o: 'r, R: Responder<'r, 'o>> Responder<'r, 'o> for NoStore { } } -/// The distinct owner addresses that hold data beyond what sign-in creates by -/// itself (`account`, and the `secrets` space's database artifacts). One -/// aggregate statement; see `tinycloud_core::data_owners`. +/// The distinct owner addresses that hold data beyond what sign-in and vault +/// unlock create by themselves (`account`, the `secrets` space's database +/// artifacts, and the vault metadata in `public`). One aggregate statement; +/// the full rule is in `tinycloud_core::data_owners`. #[get("/admin/stats")] pub async fn get_stats( _auth: StatsAuth,